597 lines
28 KiB
Python
597 lines
28 KiB
Python
"""초기 계획노선 갈래 하나 — `POST /api/projects/{id}/route/initial`(PLAN 20 · 23장).
|
||
|
||
`B05_Profile_Router_Replan`(700줄)에서 나눈 길. 같은 `router` 에 붙고 그 모듈 끝에서 불려
|
||
등록된다 — main · 시험이 `Router_Replan.router` 하나만 실어도 이 길이 함께 실림.
|
||
"""
|
||
|
||
import asyncio
|
||
import functools
|
||
import itertools
|
||
import json
|
||
import logging
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice
|
||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||
from common_util.common_util_route_geometry import expected_route_csv_path
|
||
from config.config_db import get_db_pool
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜)
|
||
_RUNNING: dict[str, dict[str, Any]] = {}
|
||
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음
|
||
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다."
|
||
|
||
|
||
@replan.router.post("/{project_id}/route/initial", response_model=None)
|
||
async def initial_route(
|
||
project_id: UUID, request: replan.RouteInitialRequest
|
||
) -> dict[str, Any] | JSONResponse:
|
||
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
|
||
|
||
작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로
|
||
`POST /route/replan`([확인]).
|
||
"""
|
||
return await compute_initial(project_id, request)
|
||
|
||
|
||
async def compute_initial(
|
||
project_id: UUID, request: replan.RouteInitialRequest, precompute: bool = False
|
||
) -> dict[str, Any] | JSONResponse:
|
||
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
|
||
|
||
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
|
||
`recompute` = [재계산] — 묶음 잠금 안에서 이 묶음 저장본 · 메모리 묶음을 버리고 새로 셈(420).
|
||
미리 셈 갈래(기본 · 저장 제약 · 종단 켬 · 설계 지형)는 `precompute`(전처리 체인)만 셈 — 화면
|
||
요청은 저장본만(엔진 소스 지문이 달라도 · 505-1 덧) · 없으면 409 `NOT_PREPARED`(505-1).
|
||
사용자가 바꾼 값 · [재계산]은 그 자리 셈.
|
||
"""
|
||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||
generate_initial_route,
|
||
load_terrain_and_streams,
|
||
)
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import attach_cross_earth
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
|
||
cover_shortfall,
|
||
project_pipes,
|
||
)
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
|
||
drop_pool_results,
|
||
forget_pool_on_standard,
|
||
is_default,
|
||
pool_lock,
|
||
read_stored,
|
||
read_stored_any,
|
||
request_fingerprint,
|
||
write_stored,
|
||
)
|
||
from B05_Profile.B05_Profile_Router_RouteInitial_Initial import initial_profile_of
|
||
from common_util.common_util_surface_confirmation import (
|
||
analysis_terrain_params,
|
||
design_terrain,
|
||
get_surface_confirmation_params,
|
||
)
|
||
|
||
weights = request.weights.model_dump() if request.weights else None
|
||
if request.mode == "weighted" and weights and abs(sum(weights.values()) - 100.0) > 0.5:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"status": "error", "message": "비중 세 값의 합은 100 이어야 합니다."},
|
||
)
|
||
try:
|
||
paths = await replan._project_paths(project_id)
|
||
except LookupError: # 없는 프로젝트 — 저장소 조회가 None 대신 던짐
|
||
paths = None
|
||
if paths is None:
|
||
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
|
||
project_root, _ = paths
|
||
current = await asyncio.to_thread(read_initial_choice, project_root)
|
||
expected = await asyncio.to_thread(replan._vertices_of, expected_route_csv_path(project_root))
|
||
if not expected:
|
||
expected = await asyncio.to_thread(replan._vertices_of, design_route_csv_path(project_root))
|
||
if len(expected) < 2:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"status": "error", "message": "예상노선이 없어 초기노선을 만들 수 없습니다."},
|
||
)
|
||
radius_m, _, _ = await replan._plan_criteria(project_id)
|
||
grade_class, design_speed, terrain_type = await replan._road_settings(project_id)
|
||
# 비포장 기준 — 포장 예외(18 %)는 포장 여부가 정해진 뒤 종단 설계가 따진다(사용자 확정 대기).
|
||
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
|
||
# 화면 제약 칸(PLAN 20장 확정) — 비운 칸은 위 프로젝트 기본값 그대로, 채운 칸만 겹쳐 쓴다.
|
||
criteria = request.criteria
|
||
if criteria and criteria.max_grade_pct is not None:
|
||
max_grade = criteria.max_grade_pct / 100.0
|
||
if criteria and criteria.min_radius_m is not None:
|
||
radius_m = criteria.min_radius_m
|
||
stream_offset_m = criteria.stream_offset_m if criteria else None
|
||
grade_smooth_m = criteria.grade_smooth_m if criteria else None
|
||
target_pct = criteria.target_grade_pct if criteria else None
|
||
pool = get_db_pool()
|
||
async with pool.acquire() as connection:
|
||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||
# 화면은 늘 분석 지형을 실음 — 설계 지형과 같으면 설계 지형(미리 셈 갈래 판정 · 505-1)
|
||
picked = None if request.terrain == design_terrain(selection) else request.terrain
|
||
selection = analysis_terrain_params(selection, picked)
|
||
inputs = {
|
||
"mode": request.mode,
|
||
"weights": weights,
|
||
"max_grade": max_grade,
|
||
"min_radius_m": radius_m,
|
||
"stream_offset_m": stream_offset_m,
|
||
"grade_smooth_m": grade_smooth_m,
|
||
"target_grade": None if target_pct is None else target_pct / 100.0,
|
||
"design_speed": design_speed or 20,
|
||
"with_profile": request.with_profile,
|
||
# 균형 종단 · 횡단 토공 표준 횡단면 = 프로젝트 저장 패널값(420)
|
||
"standard": await project_standard(project_id),
|
||
}
|
||
criteria_dump = criteria.model_dump() if criteria else None
|
||
may_compute = (
|
||
precompute
|
||
or request.recompute
|
||
or not request.with_profile
|
||
or not is_default(request.mode, weights, criteria_dump, picked, current)
|
||
)
|
||
started = time.perf_counter()
|
||
try:
|
||
terrain, streams = await asyncio.to_thread(
|
||
load_terrain_and_streams, project_root, selection
|
||
)
|
||
route = [(x, y) for x, y in expected]
|
||
key, pool_key = await asyncio.to_thread(
|
||
functools.partial(request_fingerprint, route, terrain, streams, **inputs)
|
||
)
|
||
async with pool_lock(pool_key):
|
||
if request.recompute: # 잠금 안 — 밖이면 미리 셈이 지운 것을 다시 써 버림
|
||
removed = await asyncio.to_thread(drop_pool_results, project_root, pool_key)
|
||
logger.info("추천 [재계산]: project_id=%s 저장본 %d 지움", project_id, removed)
|
||
forget_pool_on_standard(pool_key, inputs["standard"])
|
||
stored = await asyncio.to_thread(read_stored, project_root, key)
|
||
if stored is None and not may_compute:
|
||
# 프로젝트 접근 — 엔진 코드 판이 달라도 같은 입력 저장본을 꺼냄(505-1 덧 · 514-4)
|
||
found = None
|
||
for form in standard_forms(inputs["standard"]):
|
||
found = found or await asyncio.to_thread(
|
||
read_stored_any,
|
||
project_root,
|
||
pool_key,
|
||
lambda source, form=form: request_fingerprint(
|
||
route, terrain, streams, **{**inputs, "standard": form}, source=source
|
||
)[0],
|
||
)
|
||
if found is None:
|
||
return JSONResponse(
|
||
status_code=409, content={"status": "error", "message": NOT_PREPARED}
|
||
)
|
||
key, stored = found
|
||
if stored is not None:
|
||
result = stored["result"]
|
||
else:
|
||
# 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다.
|
||
result = await asyncio.to_thread(
|
||
functools.partial(
|
||
generate_initial_route, route, terrain, streams, pipes=None, **inputs
|
||
)
|
||
)
|
||
# 결과 표 절 · 성 = 횡단(420) — 저장본에 남김
|
||
await asyncio.to_thread(attach_cross_earth, result, terrain, inputs["standard"])
|
||
await asyncio.to_thread(
|
||
write_stored,
|
||
project_root,
|
||
key,
|
||
pool_key,
|
||
request.model_dump(exclude={"recompute"}),
|
||
result,
|
||
is_default(request.mode, weights, criteria_dump, picked, current),
|
||
)
|
||
# 측점 맞춘 선 · 맞추기 전 선(351-2) 둘 다 덮개 수를 지금 관으로
|
||
shown = [result, *([result["unsnapped"]] if result.get("unsnapped") else [])]
|
||
profiles = [(item["metrics"].get("profile"), item["planned"]) for item in shown]
|
||
profiles = [(p, line) for p, line in profiles if p and "error" not in p]
|
||
if request.with_profile and profiles:
|
||
pipes = await asyncio.to_thread(project_pipes, project_root)
|
||
if pipes:
|
||
for profile, line in profiles:
|
||
profile.update(cover_shortfall(profile, line, pipes))
|
||
except (FileNotFoundError, ValueError) as error:
|
||
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
|
||
# [적용]하면 설 초기 종단(476) — 같은 조각으로 미리 셈 · 저장본 · 실패 = {error}(추천은 그대로)
|
||
result = {
|
||
**result,
|
||
"initial_profile": await initial_profile_of( # 476
|
||
project_id, project_root, paths[1], result, request, selection, key, may_compute
|
||
),
|
||
}
|
||
logger.info(
|
||
"초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
|
||
request.mode,
|
||
"저장본" if stored is not None else "셈",
|
||
project_id,
|
||
time.perf_counter() - started,
|
||
result["metrics"]["length_m"],
|
||
result["metrics"]["offset_avg_m"],
|
||
)
|
||
return {
|
||
"status": "success",
|
||
"project_id": str(project_id),
|
||
**result,
|
||
"stored": stored is not None,
|
||
"current_initial": current,
|
||
}
|
||
|
||
|
||
async def project_standard(project_id: UUID) -> dict[str, Any] | None:
|
||
"""프로젝트에 저장된 표준 횡단면 설정값(B06 패널 · 확정 때 종단 options) — 없으면 None.
|
||
|
||
공식 재계산(`B06_Section_Server_Calc_Prebuild` · `stored_standard_cross_section`)과 같은
|
||
자리 · 같은 읽기 — 마지막 종단 정본 행."""
|
||
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
|
||
|
||
pool = get_db_pool()
|
||
async with pool.acquire() as connection:
|
||
async with connection.cursor() as cursor:
|
||
await cursor.execute(
|
||
"SELECT data FROM longitudinal_sections WHERE project_id = %s "
|
||
"ORDER BY id DESC LIMIT 1",
|
||
(str(project_id),),
|
||
)
|
||
row = await cursor.fetchone()
|
||
data = row[0] if row else None
|
||
if isinstance(data, str):
|
||
data = json.loads(data)
|
||
return stored_standard_cross_section({"data": data if isinstance(data, dict) else None})
|
||
|
||
|
||
def standard_forms(standard: dict[str, Any] | None) -> list[dict[str, Any] | None]:
|
||
"""같은 뜻 표준 횡단면 꼴(지금 꼴 먼저) — 없음(None) = config 기본값 · 키 · 지문은 둘을 가름 ·
|
||
[저장]은 손대지 않은 패널 기본값도 실어 보냄 → 프로젝트 접근은 두 꼴 저장본 다 꺼냄(514-4)."""
|
||
from config.config_system import STANDARD_CROSS_SECTION
|
||
|
||
if standard is None:
|
||
return [None, STANDARD_CROSS_SECTION]
|
||
return [standard, None] if standard == STANDARD_CROSS_SECTION else [standard]
|
||
|
||
|
||
async def precompute_defaults(project_id: UUID) -> None:
|
||
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 ×
|
||
고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만).
|
||
|
||
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
|
||
다섯 갈래 모두 종단 켬(420) · 첫머리에 30일 지난 다른 판 · 소스 지문 저장본을 지운다(420-2).
|
||
"""
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial import DEFAULT_WEIGHTS
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES, purge_outdated
|
||
|
||
started = time.perf_counter()
|
||
variants: list[tuple[Any, Any]] = [(None, None)]
|
||
modes = list(DEFAULT_MODES)
|
||
terrains: list[Any] = [None]
|
||
try:
|
||
paths = await replan._project_paths(project_id)
|
||
except LookupError:
|
||
paths = None
|
||
if paths is not None:
|
||
terrains = await precompute_terrains(project_id, paths[0])
|
||
removed = await asyncio.to_thread(purge_outdated, paths[0])
|
||
if removed:
|
||
logger.info("추천 옛 판 저장본 지움: project_id=%s %d", project_id, removed)
|
||
saved = await asyncio.to_thread(read_initial_choice, paths[0])
|
||
weights = saved["weights"] if saved["logic"] == "weighted" else None
|
||
if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS):
|
||
variants.append((saved["criteria"], weights))
|
||
# 모달이 여는 갈래 먼저(추적은 묶음이 선 뒤라 끝에)
|
||
modes.sort(key=lambda mode: mode != saved["logic"] or mode == "follow")
|
||
progress = _RUNNING.get(str(project_id))
|
||
if progress is not None:
|
||
progress.update(total=len(terrains) * len(variants) * len(modes))
|
||
for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes):
|
||
if progress is not None:
|
||
progress["mode"] = mode
|
||
# 화면과 같은 몸통 — 창 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
|
||
request = replan.RouteInitialRequest(
|
||
mode=mode,
|
||
with_profile=True,
|
||
criteria=criteria,
|
||
weights=weights if mode == "weighted" else None,
|
||
terrain=terrain,
|
||
)
|
||
outcome = await compute_initial(project_id, request, precompute=True)
|
||
if isinstance(outcome, JSONResponse):
|
||
logger.warning(
|
||
"추천 미리 셈 멈춤(%s %s %s): project_id=%s",
|
||
terrain,
|
||
mode,
|
||
outcome.status_code,
|
||
project_id,
|
||
)
|
||
return
|
||
if progress is not None:
|
||
progress["done"] += 1
|
||
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
|
||
|
||
|
||
async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any]:
|
||
"""미리 셈 지형(509-5) — 설계 지형 먼저 · 추천 창이 고를 수 있는 다른 지형."""
|
||
from B05_Profile.B05_Profile_Router_Terrain import _MODELS_SUBDIR, choosable_terrains
|
||
from common_util.common_util_surface_confirmation import (
|
||
design_terrain,
|
||
get_surface_confirmation_params,
|
||
)
|
||
|
||
pool = get_db_pool()
|
||
async with pool.acquire() as connection:
|
||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||
sources = await choosable_terrains(
|
||
connection, project_id, params, Path(project_root) / _MODELS_SUBDIR
|
||
)
|
||
design = design_terrain(params)
|
||
return [design, *[source for source in sources if source != design]]
|
||
|
||
|
||
async def run_precompute(project_id: UUID) -> None:
|
||
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음)."""
|
||
key = str(project_id)
|
||
if key in _RUNNING:
|
||
_RUNNING[key]["again"] = True
|
||
return
|
||
try:
|
||
while True:
|
||
_RUNNING[key] = {"done": 0, "total": 0, "mode": None, "again": False}
|
||
await precompute_defaults(project_id)
|
||
if not _RUNNING[key]["again"]:
|
||
break
|
||
finally:
|
||
_RUNNING.pop(key, None)
|
||
|
||
|
||
def precompute_progress(project_id: UUID) -> dict[str, Any]:
|
||
"""모달 진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래."""
|
||
progress = _RUNNING.get(str(project_id))
|
||
if progress is None:
|
||
return {"running": False, "done": 0, "total": 0, "mode": None}
|
||
return {"running": True, **{k: progress[k] for k in ("done", "total", "mode")}}
|
||
|
||
|
||
@replan.router.get("/{project_id}/route/initial/progress", response_model=None)
|
||
async def initial_progress(project_id: UUID) -> dict[str, Any]:
|
||
return {"status": "success", **precompute_progress(project_id)}
|
||
|
||
|
||
def schedule_precompute(project_id: UUID) -> None:
|
||
"""추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1)만 · B05 열기는 안 부름(505-1).
|
||
기다리지 않음 · 이미 돌면 끝난 뒤 한 번 더 · 실패는 로그만. 저장본이 다 있으면 꺼내기만
|
||
(갈래마다 0.2 s 안팎)."""
|
||
try:
|
||
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||
|
||
_schedule_background_task(
|
||
run_precompute(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
|
||
)
|
||
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
|
||
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
|
||
|
||
|
||
async def refresh_expected_route(project_id: UUID) -> None:
|
||
"""B01 노선 사용 범위를 바꾼 뒤(351-1) — 예상노선을 새 범위로 다시 쓰고 추천 미리 셈.
|
||
|
||
체인을 아직 안 돈 프로젝트(예상노선 없음)는 건너뜀 — 체인이 새 범위로 적는다. 설계 노선
|
||
(수정본 · 초기 폴리라인)은 그대로 — 추천의 시점 · 종점만 따라간다.
|
||
"""
|
||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||
from common_util.common_util_route_geometry import load_design_route, write_route_csv
|
||
from common_util.common_util_storage import resolve_stored_project_path
|
||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||
|
||
pool = get_db_pool()
|
||
async with pool.acquire() as connection:
|
||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||
surface = await get_surface_confirmation_params(connection, str(project_id))
|
||
async with connection.cursor() as cursor:
|
||
await cursor.execute(
|
||
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s",
|
||
(str(project_id),),
|
||
)
|
||
row = await cursor.fetchone()
|
||
if not stored_path:
|
||
return
|
||
root = Path(resolve_stored_project_path(stored_path))
|
||
path = expected_route_csv_path(root)
|
||
if not path.is_file():
|
||
return
|
||
planned = await asyncio.to_thread(
|
||
functools.partial(
|
||
load_design_route,
|
||
root,
|
||
surface,
|
||
(row[0], row[1]) if row else None,
|
||
from_source=True,
|
||
)
|
||
)
|
||
if planned is None:
|
||
logger.warning("예상노선 다시 쓰기 건너뜀(노선 못 읽음): project_id=%s", project_id)
|
||
return
|
||
points = [{"x": v.x, "y": v.y} for v in planned.vertices]
|
||
await asyncio.to_thread(write_route_csv, path, points)
|
||
logger.info("예상노선 다시 씀(사용 범위): project_id=%s 점 %d", project_id, len(points))
|
||
await run_precompute(project_id)
|
||
|
||
|
||
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
||
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
|
||
from common_util.common_util_json import atomic_write_json
|
||
|
||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||
count = seat_base_pvi(longitudinal, stations, plan)
|
||
atomic_write_json(path, longitudinal)
|
||
return count
|
||
|
||
|
||
def seat_base_pvi(longitudinal: dict[str, Any], stations: list[float], plan: list[float]) -> int:
|
||
"""1차 변화점 계획고를 균형 종단에 얹음(제자리 · 파일 안 씀) — [적용] 체인 · 추천 초기 종단(476)
|
||
한 벌."""
|
||
import numpy as np
|
||
|
||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
|
||
|
||
alignment = longitudinal["profile_alignment"]
|
||
base = alignment["base_pvi"]
|
||
base_s = np.array([float(item["chainage_m"]) for item in base])
|
||
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이
|
||
# 직선은 균형 종단 상한 안(419-3) · R > 500 m 꼭짓점 묶음은 직선화(438)
|
||
base_z = held_pass_through(
|
||
base_s, stations, plan, AlignmentPolicy.from_dict(alignment["policy"])
|
||
)
|
||
for item, value in zip(base, base_z, strict=True):
|
||
item["elevation_m"] = round(float(value), 6) # 직선 판정(426) 1e-5 m 안
|
||
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, _dropped = 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(지반고 종단).
|
||
확폭은 측점 생성과 같은 곡선표(`_load_design_curves`)로.
|
||
"""
|
||
from B05_Profile.B05_Profile_Engine_Sections import _load_design_curves
|
||
|
||
curves = await asyncio.to_thread(_load_design_curves, project_root)
|
||
profile = await balanced_profile_of(project_id, project_root, points, criteria, curves)
|
||
return None if profile is None else (profile["stations_m"], profile["plan_m"])
|
||
|
||
|
||
async def balanced_profile_of(
|
||
project_id: UUID,
|
||
project_root: Path,
|
||
points: list[dict[str, float]],
|
||
criteria: Any,
|
||
design_curves: list[dict[str, Any]] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 몫(지금 설계 지형)."""
|
||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile
|
||
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR
|
||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||
|
||
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))
|
||
standard = await project_standard(project_id) # 추천 창과 같은 저장 패널값(420)
|
||
try:
|
||
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님)
|
||
surface = await asyncio.to_thread(
|
||
build_surface_sampler,
|
||
project_root / _MODELS_SUBDIR,
|
||
str(selection.get("source_filter")),
|
||
str(selection.get("method") or "dtm"),
|
||
bool(selection.get("smooth")),
|
||
)
|
||
profile = await asyncio.to_thread(
|
||
functools.partial(
|
||
route_profile,
|
||
[(p["x"], p["y"]) for p in points],
|
||
surface,
|
||
max_grade=max_grade,
|
||
design_speed=design_speed or 20,
|
||
design_curves=design_curves,
|
||
standard=standard,
|
||
)
|
||
)
|
||
except (FileNotFoundError, ValueError):
|
||
logger.exception("균형 종단 셈 실패(지반고 종단으로): project_id=%s", project_id)
|
||
return None
|
||
logger.info(
|
||
"균형 종단 셈: project_id=%s 측점 %d 절 %.0f 성 %.0f 누가 %.0f %.1fs",
|
||
project_id,
|
||
len(profile["stations_m"]),
|
||
profile["cut_m3"],
|
||
profile["fill_m3"],
|
||
profile["mass_end_m3"],
|
||
profile["seconds"],
|
||
)
|
||
return profile
|
||
|
||
|
||
async def refresh_initial_snapshot(
|
||
project_id: UUID, project_root: Path, route_id: int | None
|
||
) -> None:
|
||
"""추천 노선 [적용](as_initial) 결과로 초기값을 다시 뜬다(346-3) — 실패는 비치명적."""
|
||
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)
|