Files
Aislo/B05_Profile/B05_Profile_Router_RouteInitial.py
T

597 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""초기 계획노선 갈래 하나 — `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)