412 lines
17 KiB
Python
412 lines
17 KiB
Python
"""B05 경로 설계 FastAPI 라우터."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
|
|
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
|
|
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
|
confirm_route,
|
|
create_route,
|
|
create_route_statistics,
|
|
get_latest_route,
|
|
get_route_points,
|
|
get_surface_crs_epsg,
|
|
insert_route_points,
|
|
)
|
|
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
|
ContourIntervalUpdateRequest,
|
|
ContourIntervalUpdateResponse,
|
|
RouteConfirmResponse,
|
|
RouteLatestResponse,
|
|
RouteSolveRequest,
|
|
RouteSolveResponse,
|
|
)
|
|
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
|
create_longitudinal_section,
|
|
delete_sections_for_route,
|
|
get_latest_section_options,
|
|
insert_cross_sections,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import (
|
|
get_surface_confirmation_params,
|
|
update_contour_interval_param,
|
|
)
|
|
from common_util.common_util_workflow_state import (
|
|
complete_stage,
|
|
fail_stage,
|
|
get_workflow_state,
|
|
start_stage,
|
|
)
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
|
|
|
|
|
def _section_options(
|
|
request: RouteSolveRequest, stored_options: dict[str, Any] | None
|
|
) -> SectionGenerationOptions:
|
|
"""요청 값 → DB 저장 옵션(단일 소스) → config 기본값 순으로 결정한다."""
|
|
defaults = SectionGenerationOptions()
|
|
stored = stored_options or {}
|
|
return SectionGenerationOptions(
|
|
station_interval_m=request.station_interval_m
|
|
or stored.get("station_interval_m")
|
|
or defaults.station_interval_m,
|
|
cross_half_width_m=request.cross_half_width_m
|
|
or stored.get("cross_half_width_m")
|
|
or defaults.cross_half_width_m,
|
|
cross_sample_interval_m=request.cross_sample_interval_m
|
|
or stored.get("cross_sample_interval_m")
|
|
or defaults.cross_sample_interval_m,
|
|
long_sample_interval_m=request.long_sample_interval_m
|
|
or stored.get("long_sample_interval_m")
|
|
or defaults.long_sample_interval_m,
|
|
include_endpoint=defaults.include_endpoint,
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
|
|
async def solve_route(
|
|
project_id: UUID, request: RouteSolveRequest
|
|
) -> RouteSolveResponse | JSONResponse:
|
|
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
params = {
|
|
"filter_key": request.filter_key,
|
|
"method": request.method,
|
|
"smooth": request.smooth,
|
|
"points": request.points_data(),
|
|
"options": request.options(),
|
|
"algorithm": request.algorithm,
|
|
"surface_model_id": request.surface_model_id,
|
|
"station_interval_m": request.station_interval_m,
|
|
"cross_half_width_m": request.cross_half_width_m,
|
|
"cross_sample_interval_m": request.cross_sample_interval_m,
|
|
"long_sample_interval_m": request.long_sample_interval_m,
|
|
}
|
|
log_b05_debug(
|
|
logger,
|
|
"request.solve",
|
|
project_id=str(project_id),
|
|
workflow_stage_params=params,
|
|
)
|
|
async with pool.acquire() as connection:
|
|
async with connection.cursor() as cursor:
|
|
await start_stage(cursor, str(project_id), 2, params)
|
|
await connection.commit()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.project_workflow_stages.start_stage_committed",
|
|
project_id=str(project_id),
|
|
stage_no=2,
|
|
params=params,
|
|
)
|
|
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
|
|
# 무거운 경로 탐색은 이벤트 루프를 막지 않도록 별도 스레드에서 실행.
|
|
design = await asyncio.to_thread(
|
|
run_route_design,
|
|
project_root,
|
|
request.filter_key,
|
|
request.method,
|
|
request.smooth,
|
|
request.points_data(),
|
|
request.options(),
|
|
request.algorithm,
|
|
)
|
|
solver = design["solver_result"]
|
|
metrics = solver["metrics"]
|
|
log_b05_debug(
|
|
logger,
|
|
"calculation.complete",
|
|
project_id=str(project_id),
|
|
metrics=metrics,
|
|
required_points_ok=solver.get("required_points_ok"),
|
|
route_data_path=design["route_data_path"],
|
|
)
|
|
|
|
await connection.begin()
|
|
try:
|
|
route_record = {
|
|
"project_id": str(project_id),
|
|
"surface_model_id": request.surface_model_id,
|
|
"status": "DRAFT",
|
|
"start_chainage_m": 0.0,
|
|
"end_chainage_m": metrics.get("length_m"),
|
|
"total_length_m": metrics.get("length_m"),
|
|
"grade_percent": design["grade_percent"],
|
|
"constraints": design["constraints"],
|
|
"algorithm_params": {
|
|
**design["algorithm_params"],
|
|
"metrics": metrics,
|
|
"curve_warning_segments": solver.get("curve_warning_segments", []),
|
|
},
|
|
"route_data_path": design["route_data_path"],
|
|
}
|
|
log_b05_debug(logger, "db.routes.insert", record=route_record)
|
|
route_id = await create_route(
|
|
connection,
|
|
project_id=project_id,
|
|
surface_model_id=route_record["surface_model_id"],
|
|
total_length_m=route_record["total_length_m"],
|
|
start_chainage_m=route_record["start_chainage_m"],
|
|
end_chainage_m=route_record["end_chainage_m"],
|
|
grade_percent=route_record["grade_percent"],
|
|
constraints=route_record["constraints"],
|
|
algorithm_params=route_record["algorithm_params"],
|
|
route_data_path=route_record["route_data_path"],
|
|
)
|
|
render_points = design["render_points"]
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_points.insert_many",
|
|
route_id=route_id,
|
|
row_count=len(render_points),
|
|
first_row=render_points[0] if render_points else None,
|
|
last_row=render_points[-1] if render_points else None,
|
|
)
|
|
await insert_route_points(connection, route_id, render_points)
|
|
stats = design["statistics"]
|
|
statistics_record = {
|
|
"route_id": route_id,
|
|
"min_slope": stats["min_slope"],
|
|
"max_slope": stats["max_slope"],
|
|
"mean_slope": stats["mean_slope"],
|
|
"cost_score": stats["cost_score"],
|
|
}
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_statistics.insert",
|
|
record=statistics_record,
|
|
)
|
|
await create_route_statistics(
|
|
connection,
|
|
**statistics_record,
|
|
)
|
|
await connection.commit()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_transaction.committed",
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
)
|
|
except Exception as exc:
|
|
await connection.rollback()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_transaction.rolled_back",
|
|
project_id=str(project_id),
|
|
reason=str(exc),
|
|
)
|
|
raise
|
|
|
|
# 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다.
|
|
longitudinal_length_m: float | None = None
|
|
cross_section_count: int | None = None
|
|
try:
|
|
crs_epsg = await get_surface_crs_epsg(
|
|
connection, project_id, request.surface_model_id
|
|
)
|
|
stored_options = await get_latest_section_options(connection, project_id)
|
|
sections = await asyncio.to_thread(
|
|
run_section_generation,
|
|
project_root,
|
|
design["route_data_path"],
|
|
request.filter_key,
|
|
request.method,
|
|
request.smooth,
|
|
options=_section_options(request, stored_options),
|
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
await delete_sections_for_route(connection, route_id)
|
|
await create_longitudinal_section(
|
|
connection,
|
|
project_id=project_id,
|
|
route_id=route_id,
|
|
data=sections["longitudinal"]["data"],
|
|
longitudinal_file_path=sections["longitudinal"]["file_path"],
|
|
)
|
|
await insert_cross_sections(
|
|
connection,
|
|
project_id=project_id,
|
|
route_id=route_id,
|
|
sections=sections["cross_sections"],
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
|
|
cross_section_count = len(sections["cross_sections"])
|
|
except Exception:
|
|
logger.exception(
|
|
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
|
|
project_id,
|
|
route_id,
|
|
)
|
|
|
|
return RouteSolveResponse(
|
|
project_id=str(project_id),
|
|
route_id=route_id,
|
|
total_length_m=metrics.get("length_m", 0.0),
|
|
metrics=metrics,
|
|
required_points_ok=solver["required_points_ok"],
|
|
route_data_path=design["route_data_path"],
|
|
longitudinal_length_m=longitudinal_length_m,
|
|
cross_section_count=cross_section_count,
|
|
)
|
|
except LookupError as exc:
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except FileNotFoundError as exc:
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except (OSError, ValueError) as exc:
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception as exc:
|
|
logger.exception("B05 경로 탐색 실패: project_id=%s", project_id)
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await fail_stage(cursor, str(project_id), 2, str(exc))
|
|
await connection.commit()
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "경로 탐색 처리 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/route/contour-interval", response_model=ContourIntervalUpdateResponse)
|
|
async def update_contour_interval(
|
|
project_id: UUID, request: ContourIntervalUpdateRequest
|
|
) -> ContourIntervalUpdateResponse | JSONResponse:
|
|
"""B05 등고선 간격 재적용 값을 stage 1 params(단일 소스)에 영속화한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
await connection.begin()
|
|
try:
|
|
await update_contour_interval_param(
|
|
connection, str(project_id), request.contour_interval_m
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
return ContourIntervalUpdateResponse(
|
|
project_id=str(project_id), contour_interval_m=request.contour_interval_m
|
|
)
|
|
except LookupError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B05 등고선 간격 저장 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "등고선 간격 저장 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.get("/{project_id}/route/latest", response_model=RouteLatestResponse)
|
|
async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse:
|
|
"""최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
latest = await get_latest_route(connection, project_id)
|
|
route_points = await get_route_points(connection, latest["id"]) if latest else []
|
|
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
|
workflow = await get_workflow_state(cursor, str(project_id))
|
|
route_stage = next(
|
|
(stage for stage in workflow["stages"] if stage["stage_no"] == 2),
|
|
None,
|
|
)
|
|
return RouteLatestResponse(
|
|
project_id=str(project_id),
|
|
route=latest,
|
|
route_points=route_points,
|
|
surface_params=surface_params,
|
|
route_params=route_stage.get("params") if route_stage else None,
|
|
)
|
|
except Exception:
|
|
logger.exception("B05 최신 경로 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "최신 경로 조회 중 오류가 발생했습니다."},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
|
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
|
|
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
|
|
pool = get_db_pool()
|
|
try:
|
|
async with pool.acquire() as connection:
|
|
latest = await get_latest_route(connection, project_id)
|
|
if not latest:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "확정할 경로가 없습니다."},
|
|
)
|
|
await connection.begin()
|
|
try:
|
|
log_b05_debug(
|
|
logger,
|
|
"db.routes.confirm",
|
|
project_id=str(project_id),
|
|
route_id=latest["id"],
|
|
previous_status=latest["status"],
|
|
next_status="CONFIRMED",
|
|
)
|
|
await confirm_route(connection, latest["id"])
|
|
async with connection.cursor() as cursor:
|
|
await complete_stage(cursor, str(project_id), 2)
|
|
await connection.commit()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_confirmation.committed",
|
|
project_id=str(project_id),
|
|
route_id=latest["id"],
|
|
completed_stage=2,
|
|
)
|
|
except Exception as exc:
|
|
await connection.rollback()
|
|
log_b05_debug(
|
|
logger,
|
|
"db.route_confirmation.rolled_back",
|
|
project_id=str(project_id),
|
|
route_id=latest["id"],
|
|
reason=str(exc),
|
|
)
|
|
raise
|
|
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
|
|
except Exception:
|
|
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "경로 확정 처리 중 오류가 발생했습니다."},
|
|
)
|