Files
Aislo/B05_wf2_Route/B05_wf2_Route_Router.py
T
2026-07-19 10:45:22 +09:00

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": "경로 확정 처리 중 오류가 발생했습니다."},
)