fix(B05·B06): 노선 [확인] 이월은 사용자 입력만 · 추천 균형 종단 1차 종단 · 확정 노선 = 새 초기값(74-3·74-4·74-6)

- [확인] 체인 — 옛 설계 통째 이월 대신 사용자 입력 칸(CARRY_INPUT_KEYS)만 누가거리로 얹고 전 측점을 새 계획고로 다시 셈 · 새 노선에 없는 누가거리 행 안 만듦
- 「종단 반영」 켬으로 [적용]한 추천 그대로 [확인] = 서버가 같은 코드(route_profile)로 균형 종단을 다시 셈해 base_pvi 로 · 요청엔 켬/끔 표시만
- [확인] 성공 뒤 initial_snapshot 다시 뜸(save_initial_snapshot 재사용)
- [확인] 안내 글(title) · 「종단 반영」 체크박스를 갈래 알약 줄 오른쪽 토글 단추로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-07 18:32:33 +09:00
co-authored by Claude Opus 5.5
parent 93869d3314
commit 98a15432b8
10 changed files with 346 additions and 49 deletions
+39 -10
View File
@@ -15,6 +15,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다.
"""
import asyncio
import logging
import time
from pathlib import Path
@@ -393,6 +394,7 @@ async def run_redesign_chain(
project_id: UUID,
surface_model_id: int,
selection: dict[str, Any],
profile_target: tuple[list[float], list[float]] | None = None,
) -> str | None:
"""B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장.
@@ -406,6 +408,9 @@ async def run_redesign_chain(
측점은 확정 시 기본값으로 채워진다.
경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다.
`profile_target`(누가거리 · 계획고)이 오면 1차 종단을 그 계획고로 세운다 — 노선 추천
「종단 반영」 켬으로 [적용]한 노선의 균형 종단(PLAN 74-4). 없으면 계획고 = 지반고.
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route
@@ -416,7 +421,8 @@ async def run_redesign_chain(
get_longitudinal_section,
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import confirm_sections
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util.common_util_initial_snapshot import (
clear_designing,
@@ -557,14 +563,40 @@ async def run_redesign_chain(
marks.append(("배수·관·측점 재생성", time.perf_counter()))
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 설계를 **누가거리로** 얹는다.
# 묶어 쓰므로 행 수와 무관하게 왕복 두 번이다(`merge_cross_section_designs`).
if profile_target is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
await apply_profile_target(project_id, new_route_id, profile_target)
marks.append(("균형 종단 반영", time.perf_counter()))
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
standard = old_options.get("standard_cross_section") or None
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 **입력**만 누가거리로 얹고 전 측점을
# 새 계획고로 다시 셈한다(PLAN 74-3). 결과 칸(계획고 · 면적 · design_line …)을 통째
# 옮기면 옛 노선 단면이 새 노선에 남아 누가토량이 두 배가 됐다(74-1). 새 노선에 없는
# 누가거리는 행을 만들지 않는다.
try:
entries = [
(float(record["chainage_m"]), record["design"])
inputs = {
round(float(record["chainage_m"]), 3): {
key: record["design"][key]
for key in CARRY_INPUT_KEYS
if record["design"].get(key) is not None
}
for record in old_designs
if isinstance(record.get("design"), dict)
]
}
async with pool.acquire() as connection:
new_longitudinal = await get_longitudinal_section(
connection, project_id, new_route_id
)
long_path = str((new_longitudinal or {}).get("longitudinal_file_path") or "")
chainages = await asyncio.to_thread(
_rowless_station_chainages, project_root, long_path, []
)
entries = await asyncio.to_thread(
compute_default_designs, project_root, long_path, chainages, standard, inputs
)
async with pool.acquire() as connection:
await connection.begin()
try:
@@ -587,11 +619,8 @@ async def run_redesign_chain(
marks.append(("설계 이월", time.perf_counter()))
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
section_request = (
SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"])
if old_options.get("standard_cross_section")
else None
SectionConfirmRequest(standard_cross_section=standard) if standard else None
)
sections_result = await confirm_sections(
project_id, new_route_id, section_request, mark_stage_complete=False
+29 -4
View File
@@ -128,6 +128,9 @@ class RouteReplanRequest(BaseModel):
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
@@ -375,8 +378,16 @@ def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int
return len(dense)
async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> dict[str, Any]:
"""노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다."""
async def _recompute(
project_id: UUID,
project_root: Path,
stored_path: str,
balanced: RouteInitialRequest | None = None,
) -> dict[str, Any]:
"""노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다.
`balanced`(추천 갈래 · 제약)가 오면 그 제약으로 균형 종단을 셈해 1차 종단으로 쓴다(PLAN 74-4).
"""
import aiomysql
from B03_FileInput.B03_FileInput_Service_Chain import (
@@ -454,7 +465,12 @@ async def _recompute(project_id: UUID, project_root: Path, stored_path: str) ->
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
failure = await run_redesign_chain(project_id, int(surface_model_id), selection)
target = None
if balanced is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import balanced_target
target = await balanced_target(project_id, project_root, points, balanced.criteria)
failure = await run_redesign_chain(project_id, int(surface_model_id), selection, target)
marks.append(("재확정 체인", time.perf_counter()))
_log_steps("노선 [확인] 재계산", marks)
if failure:
@@ -590,7 +606,12 @@ async def replan_route(
summary["violations"],
radius_m,
)
result = await _recompute(project_id, project_root, stored_path)
result = await _recompute(
project_id,
project_root,
stored_path,
(request.initial or RouteInitialRequest()) if request.balanced_profile else None,
)
if "error" in result:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
if previous is None:
@@ -617,6 +638,10 @@ async def replan_route(
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
)
# 확정 노선 = 새 초기값(PLAN 74-4) — [저장] 없이 이 결과를 [초기화]가 돌아갈 자리로 뜬다.
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot
await refresh_initial_snapshot(project_id, project_root, result.get("route_id"))
return {
"status": "success",
"project_id": str(project_id),
@@ -8,6 +8,7 @@ import asyncio
import functools
import logging
import time
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -118,3 +119,131 @@ async def initial_route(
**result,
"current_initial": current,
}
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
import json
import numpy as np
from common_util.common_util_json import atomic_write_json
longitudinal = json.loads(path.read_text(encoding="utf-8"))
base = longitudinal["profile_alignment"]["base_pvi"]
for item in base:
item["elevation_m"] = round(float(np.interp(float(item["chainage_m"]), stations, plan)), 4)
atomic_write_json(path, longitudinal)
return len(base)
async def apply_profile_target(
project_id: UUID, route_id: int, target: tuple[list[float], list[float]]
) -> None:
"""[확인] 체인의 1차 종단을 추천 균형 종단(누가거리 · 계획고)으로 갈아 끼운다(PLAN 74-4).
1차 종단(`design_ground_following_profile`)은 측점마다 계획고 = 지반고로 맞춘다 — 그 맞춤 대신
변화점(`base_pvi`)을 균형 종단에 놓고 `rebuild_alignment_profile` 로 계획선을 다시 낸다.
구조물 · 관 측점은 앞뒤 20 m 측점 사이 직선 보간. 실패는 비치명적 — 지반고 종단이 남는다.
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
from B06_Section.B06_Section_Repository import get_longitudinal_section
from common_util.common_util_storage import resolve_stored_project_path
pool = get_db_pool()
try:
async with pool.acquire() as connection:
row = await get_longitudinal_section(connection, project_id, route_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
root = Path(resolve_stored_project_path(stored_path))
relative = str(row["longitudinal_file_path"])
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {})
async with pool.acquire() as connection:
await connection.begin()
await update_longitudinal_grade_summary(
connection, route_id=route_id, grade_summary=summary
)
await connection.commit()
logger.info(
"균형 종단 반영: project_id=%s route_id=%s 변화점 %d", project_id, route_id, count
)
except Exception: # noqa: BLE001 — 지반고 종단이 남는다
logger.exception("균형 종단 반영 실패(지반고 종단 유지): project_id=%s", project_id)
async def balanced_target(
project_id: UUID,
project_root: Path,
points: list[dict[str, float]],
criteria: Any,
) -> tuple[list[float], list[float]] | None:
"""확정할 노선의 균형 종단(누가거리 · 계획고) — 추천 창과 같은 코드 · 같은 상한(PLAN 74-4).
화면 값을 받지 않고 노선 정점으로 다시 셈한다(CLAUDE.md 5). 못 세우면 None(지반고 종단).
"""
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
grade_class, design_speed, terrain_type = await replan._road_settings(project_id)
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
if criteria is not None and criteria.max_grade_pct is not None:
max_grade = criteria.max_grade_pct / 100.0
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
try:
terrain, _streams = await asyncio.to_thread(
load_terrain_and_streams, project_root, selection
)
profile = await asyncio.to_thread(
functools.partial(
route_profile,
[(p["x"], p["y"]) for p in points],
terrain,
max_grade=max_grade,
design_speed=design_speed or 20,
)
)
except (FileNotFoundError, ValueError):
logger.exception("균형 종단 셈 실패(지반고 종단으로): project_id=%s", project_id)
return None
logger.info(
"균형 종단 셈: project_id=%s 측점 %d 절 %.0f 성 %.0f %.1fs",
project_id,
len(profile["stations_m"]),
profile["cut_m3"],
profile["fill_m3"],
profile["seconds"],
)
return profile["stations_m"], profile["plan_m"]
async def refresh_initial_snapshot(
project_id: UUID, project_root: Path, route_id: int | None
) -> None:
"""노선 [확인] 결과로 초기값(initial_snapshot)을 다시 뜬다(PLAN 74-4) — 실패는 비치명적."""
from common_util.common_util_initial_snapshot import (
discard_initial_snapshot,
save_initial_snapshot,
)
if route_id is None:
return
try:
# 스냅샷 안 `planned_route.csv` 는 예상노선(원본) 자리 — 확정 노선 아닌 예상노선을 둔다.
expected = await asyncio.to_thread(
replan._vertices_of, expected_route_csv_path(project_root)
)
points = [{"x": x, "y": y} for x, y in expected] or None
await asyncio.to_thread(discard_initial_snapshot, project_root)
pool = get_db_pool()
async with pool.acquire() as connection:
await save_initial_snapshot(connection, project_root, int(route_id), points)
logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id)
except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다
logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id)
@@ -26,7 +26,11 @@ export interface RouteApplyParams {
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
changed: () => boolean;
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
initial: { current: () => unknown; keep: () => void };
initial: {
current: () => unknown;
keep: () => void;
profiled: (nodes: ReturnType<RouteApplyParams["nodes"]>) => boolean;
};
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
@@ -85,7 +89,10 @@ export function bindRouteApply(params: RouteApplyParams): void {
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
{ initial: params.initial.current() },
{
initial: params.initial.current(),
balanced_profile: params.initial.profiled({ planned, curveOn, curveRadius }),
},
),
);
});
@@ -58,7 +58,8 @@ export function createRouteEditChrome(): RouteEditChrome {
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">확인</button>
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
</div>
@@ -58,6 +58,8 @@ export interface InitialPanel {
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
};
/** 「종단 반영」 켬으로 [적용]한 추천 노선 그대로인가 — [확인]이 균형 종단을 1차 종단으로 쓸지(PLAN 74-4). */
profiled: (nodes: Pick<RouteEditSnapshot, "planned" | "curveOn" | "curveRadius">) => boolean;
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
tag: () => InitialChoice | null;
restoreTag: (tag: unknown) => void;
@@ -214,6 +216,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
},
profiled: (nodes) => {
if (!choice?.balance) return false; // 끔 · [초기화] · 손 편집만 한 노선
const { planned, curveOn, curveRadius } = choice.snapshot;
return (
JSON.stringify([planned, curveOn, curveRadius]) ===
JSON.stringify([nodes.planned, nodes.curveOn, nodes.curveRadius])
);
},
tag: () => choice,
restoreTag(tag) {
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
@@ -14,7 +14,6 @@
* ========================================================================== */
import { openModal } from "@ui/ui_template_modal";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { showToast } from "@ui/ui_template_elements";
import type {
RouteInitialCriteria,
@@ -185,11 +184,16 @@ export function openRecommendModal(params: RecommendParams): void {
bodyEl = body;
body.classList.add("b05-routerec__body");
body.innerHTML = `
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
${PILLS.map(
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
).join("")}
<div class="b05-routerec__pillrow">
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
${PILLS.map(
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
).join("")}
</div>
<button type="button" class="b05-routerec__pill b05-routerec__profile-toggle"
data-with-profile aria-pressed="true"
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름) · 이 창 안에서만">종단 반영</button>
</div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
@@ -219,10 +223,6 @@ export function openRecommendModal(params: RecommendParams): void {
</div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<div class="b05-routerec__profile-opt" data-profile-opt
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름)">
<small>끄면 원지반만 · 이 창 안에서만</small>
</div>
<div class="b05-routerec__criteria">
${CRITERIA_KEYS.map(
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
@@ -267,12 +267,7 @@ export function openRecommendModal(params: RecommendParams): void {
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
const profileCheck = createCheckbox("종단 반영", {
checked: params.profileMemory.withProfile,
});
profileCheck.input.dataset.withProfile = "";
q<HTMLElement>("[data-profile-opt]").prepend(profileCheck.root);
const profileBox = profileCheck.input;
const profileToggle = q<HTMLButtonElement>("[data-with-profile]");
bindRecommendHelp(body);
criteriaInputs.forEach((input) => {
@@ -351,7 +346,8 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
profileBox.disabled = busy;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(params.profileMemory.withProfile));
rerun.hidden = !dirty;
rerun.disabled = busy || invalid.size > 0;
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
@@ -517,8 +513,8 @@ export function openRecommendModal(params: RecommendParams): void {
render();
});
});
profileBox.addEventListener("change", () => {
params.profileMemory.withProfile = profileBox.checked;
profileToggle.addEventListener("click", () => {
params.profileMemory.withProfile = !params.profileMemory.withProfile;
dirty = preview !== null;
render();
});
@@ -117,16 +117,6 @@
display: none;
}
.b05-routerec__profile-opt {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b05-routerec__profile-opt small {
color: var(--color-text-secondary);
}
.b05-routerec__help-item p,
.b05-routerec__help dd {
margin: 2px 0;
@@ -222,6 +212,34 @@
font-weight: 600;
}
/* 갈래 알약 오른쪽 「종단 반영」 켬/끔 — 알약과 같은 모양 · 켬은 테두리 · 글자만 강조(고른 갈래와 갈림). */
.b05-routerec__pillrow {
display: flex;
gap: var(--spacing-8);
align-items: stretch;
}
.b05-routerec__pillrow > .b05-routerec__pills {
flex: 1;
min-width: 0;
}
.b05-routerec__profile-toggle {
flex: none;
padding-inline: var(--spacing-12);
}
.b05-routerec__profile-toggle[aria-pressed="true"] {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
color: var(--color-primary-text, var(--color-primary));
font-weight: 600;
}
.b05-routerec__profile-toggle[aria-pressed="true"]::before {
content: "✓ ";
}
.b05-routerec__pill:disabled {
cursor: progress;
opacity: 0.7;
+32 -3
View File
@@ -145,6 +145,20 @@ USER_TOUCHED_KEYS = (
"cut_slope_ratio_user",
)
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
# design_line · road_edges 같은 결과 칸과 단면 유형 · 측구 방향(새 노선 지형이 정함) · 구조물
# 조작값은 넘기지 않는다. 받는 쪽은 `compute_default_designs(carried=…)`.
CARRY_INPUT_KEYS = (
"ground_type",
"ditch_type",
"ditch_choice",
"paved",
"two_stage_slope",
"rock_boundary_offset_m",
"cut_slope_ratio_user",
"berm",
)
def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None:
"""확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값).
@@ -474,10 +488,13 @@ def compute_default_designs(
longitudinal_file_path: str,
chainages: list[float],
standard: dict[str, Any] | None = None,
carried: dict[float, dict[str, Any]] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로
계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
@@ -501,18 +518,30 @@ def compute_default_designs(
if not isinstance(samples, list):
continue
suggested = pavement.get(round(chainage_m, 3), False)
user = (carried or {}).get(round(chainage_m, 3)) or {}
rock_offset = user.get("rock_boundary_offset_m")
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="ripping_rock",
ground_type=str(user.get("ground_type") or "ripping_rock"),
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
ditch_type=str(user.get("ditch_type") or "standard"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges),
paved=paved_at(chainage_m, paved_ranges, user.get("paved")),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
rock_boundary_offset_m=(
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
),
two_stage_slope=bool(user.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(user),
ditch_choice=user.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
berm=stored_berm(user),
**curve_widening_args(cross),
)
for key in ("berm", "cut_slope_ratio_user"):
if user.get(key) is not None:
design[key] = user[key]
design["status"] = "provisional"
design["pavement_suggested"] = suggested
results.append((chainage_m, design))
@@ -0,0 +1,53 @@
"""노선 [확인] 체인 이월 · 균형 종단 반영(PLAN 74-3 · 74-4).
./venv/Scripts/python.exe -m pytest resources/tester/test_74_route_confirm_initial.py -q
"""
import json
from pathlib import Path
from B05_Profile.B05_Profile_Router_Replan import RouteReplanRequest
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS
ROOT = Path(__file__).resolve().parents[2]
def test_이월은_사용자_입력만() -> None:
for result_key in (
"design_elevation_m",
"cut_area_m2",
"fill_area_m2",
"design_line",
"road_edges",
"slope_unclosed",
"section_mode",
"ditch_side",
):
assert result_key not in CARRY_INPUT_KEYS
assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None:
source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text(encoding="utf-8")
assert 'record["design"])' not in source # 옛 (누가거리, design 통째) 이월
assert "compute_default_designs, project_root, long_path, chainages, standard, inputs" in (
" ".join(source.split())
)
def test_변화점_계획고를_균형_종단으로(tmp_path: Path) -> None:
path = tmp_path / "longitudinal.json"
base = [{"chainage_m": s, "elevation_m": 100.0} for s in (0.0, 20.0, 30.0, 40.0)]
path.write_text(json.dumps({"profile_alignment": {"base_pvi": base}}), encoding="utf-8")
assert _set_base_pvi(path, [0.0, 20.0, 40.0], [10.0, 12.0, 16.0]) == 4
got = [
p["elevation_m"]
for p in json.loads(path.read_text("utf-8"))["profile_alignment"]["base_pvi"]
]
assert got == [10.0, 12.0, 14.0, 16.0] # 구조물 측점(30 m)은 직선 보간
def test_확인_요청_기본은_끔() -> None:
assert RouteReplanRequest().balanced_profile is False
assert RouteReplanRequest(balanced_profile=True).balanced_profile is True