Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_Router.py
T
eomsangdonandClaude Opus 5.5 17534a01ad feat(530-1 · 530-2): D 예정노선 지형 받기 · 2단계 분석 서버 — B05 엔진 인자 셋(models_dir · cache_dir · 복도 반폭) · D03 지형 서비스 · 분석 · API
- B05 지형 로더 models_dir · cache_dir=None · 비용면 cache_dir=None · 탐색 복도 corridor_m(기본 100 m · 기본이면 묶음 열쇠 그대로)
- D03_PlanAnalysis_Terrain — 지점 + 1 km 표본 3×3 도엽 · prune 없음 · 병합 옛 ∪ 새 · 위성 합집합 · 연도별 models_<연도> DEM · plan.prj
- D03_PlanAnalysis_Analyze — 연도 구간마다 갈래 넷 · 법정 기준 · 누가 이어 셈 · 파생 저장본(입력 지문)
- D03_PlanAnalysis_Router — POST plan/terrain · plan/analyze(뒤에서 · 진행률) · GET plan/progress · plan/analysis · PUT [저장]
- 지킴 시험 test_530_1_plan_terrain · test_530_2_plan_analyze · test_530_2_plan_router

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:38:40 +09:00

251 lines
11 KiB
Python

"""D 예정노선 2단계 분석 API (요구 345 · PLAN 530-1 · 530-2).
- `POST /api/projects/{id}/plan/terrain` — 지형 받기(뒤에서) · 끝나면 연도별 `surface_models` 행
- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본)
- `GET …/plan/progress` — 두 일의 진행률
- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선
- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래 → `plan_route_years.route_json` · `fingerprint`
지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫)을 읽기만 함. 계산 자리 = ③ 서버 단독 —
[저장]은 브라우저 값을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈.
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"])
# 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
class ChoiceRequest(BaseModel):
choices: dict[int, str] # 연도 → 갈래
async def _plan_inputs(project_id: UUID) -> tuple[Path, dict[str, Any], list[dict[str, Any]]]:
"""(프로젝트 실경로, 프로젝트 칸, 연도 구간) — 지점이 빈 연도 · 좌표계 섞임은 거절."""
async with get_db_pool().acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT road_type, design_speed_kph, route_start_m FROM projects WHERE id = %s",
(str(project_id),),
)
project = await cursor.fetchone()
await cursor.execute(
"SELECT year, start_x, start_y, end_x, end_y, epsg, route_json, fingerprint "
"FROM plan_route_years WHERE project_id = %s ORDER BY year",
(str(project_id),),
)
rows = await cursor.fetchall()
if not stored or not project:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
if not rows:
raise HTTPException(
status_code=400, detail="연도별 지점이 없습니다 — 1단계에서 지정하세요."
)
for row in rows:
if any(row[k] is None for k in ("start_x", "start_y", "end_x", "end_y", "epsg")):
raise HTTPException(status_code=400, detail=f"{row['year']}년 지점이 비었습니다.")
if len({int(row["epsg"]) for row in rows}) != 1:
raise HTTPException(status_code=400, detail="연도마다 좌표계가 다릅니다.")
segments = [
{
"year": int(row["year"]),
"start": (float(row["start_x"]), float(row["start_y"])),
"end": (float(row["end_x"]), float(row["end_y"])),
"epsg": int(row["epsg"]),
"route_json": row["route_json"],
}
for row in rows
]
return Path(resolve_stored_project_path(stored)), project, segments
def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
key = (str(project_id), kind)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None}
_JOBS[key] = job
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
async def run() -> None:
try:
await work(report)
job.update(percent=100)
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("D %s 실패: project_id=%s", kind, project_id)
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted", **job}
async def _save_models(project_id: UUID, years: dict[int, list[dict[str, Any]]], epsg: int) -> None:
"""연도별 지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로)."""
from B04_PreProcess.B04_PreProcess_Repository import _existing_models, _upsert_surface_model
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
existing = await _existing_models(connection, project_id)
for models in years.values():
for model in models:
await _upsert_surface_model(
connection,
existing,
project_id=project_id,
model=model,
source_file_id=None,
processed_cloud_id=None,
crs_epsg=epsg,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
@router.post("/{project_id}/plan/terrain", response_model=None)
async def plan_terrain(project_id: UUID) -> dict[str, Any]:
"""지점 기준 수치지형도 · 위성 · 연도별 DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
root, _, segments = await _plan_inputs(project_id)
epsg = segments[0]["epsg"]
async def work(report: Any) -> None:
got = await asyncio.to_thread(terrain.ensure_plan_terrain, root, segments, epsg, report)
await _save_models(project_id, got["years"], epsg)
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
return _start_job(project_id, "terrain", work)
@router.post("/{project_id}/plan/analyze", response_model=None)
async def plan_analyze(project_id: UUID) -> dict[str, Any]:
"""연도 구간마다 노선 추천 갈래 넷(뒤에서 · 같은 입력이면 저장본)."""
root, project, segments = await _plan_inputs(project_id)
criteria = analyze.plan_criteria(project["road_type"], project["design_speed_kph"])
missing = [s["year"] for s in segments if not terrain.models_dir_of(root, s["year"]).is_dir()]
if missing:
raise HTTPException(status_code=409, detail=f"지형을 먼저 받으세요: {missing}년")
async def work(report: Any) -> None:
await asyncio.to_thread(
lambda: analyze.analyze_plan(root, segments, criteria, on_progress=report)
)
return _start_job(project_id, "analyze", work)
@router.get("/{project_id}/plan/progress", response_model=None)
async def plan_progress(project_id: UUID) -> dict[str, Any]:
idle = {"running": False, "percent": 0, "message": None, "error": None}
return {
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}
def _saved_mode(route_json: Any) -> str | None:
try:
value = json.loads(route_json) if isinstance(route_json, str) else route_json
except ValueError:
return None
return value.get("mode") if isinstance(value, dict) else None
def _assemble(
root: Path, project: dict[str, Any], segments: list[dict[str, Any]], choices: dict[int, Any]
) -> tuple[list[dict[str, Any] | None], list[dict[str, Any]] | None, list[list[float]]]:
"""(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선."""
results = [analyze.read_segment(root, seg) for seg in segments]
if any(result is None for result in results):
return results, None, []
rows = analyze.chain(results, choices, float(project["route_start_m"] or 0.0))
return results, rows, analyze.joined_line(results, rows)
@router.get("/{project_id}/plan/analysis", response_model=None)
async def plan_analysis(project_id: UUID) -> dict[str, Any]:
"""분석 저장본(지점이 지금과 같은 것만) + 고른 갈래(저장값 · 없으면 첫 성공 갈래)."""
root, project, segments = await _plan_inputs(project_id)
choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments}
results, rows, line = await asyncio.to_thread(_assemble, root, project, segments, choices)
return {
"status": "success",
"years": [
{"year": seg["year"], "start": seg["start"], "end": seg["end"], "analysis": result}
for seg, result in zip(segments, results)
],
"chain": rows,
"line": line,
}
@router.put("/{project_id}/plan/analysis", response_model=None)
async def save_plan_analysis(project_id: UUID, request: ChoiceRequest) -> dict[str, Any]:
"""[저장] — 연도별 고른 갈래를 서버 저장본으로 다시 이어 `plan_route_years` 에 씀."""
root, project, segments = await _plan_inputs(project_id)
bad = [m for m in request.choices.values() if m not in analyze.PLAN_MODES]
if bad:
raise HTTPException(status_code=400, detail=f"모르는 갈래: {bad}")
choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments}
choices.update(request.choices)
results, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices)
if rows is None:
raise HTTPException(
status_code=409, detail="분석 저장본이 없습니다 — [분석]을 먼저 하세요."
)
lost = [r["year"] for r in rows if choices.get(r["year"]) not in (None, r["mode"])]
if lost:
raise HTTPException(status_code=409, detail=f"고른 갈래에 노선이 없습니다: {lost}년")
async with get_db_pool().acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
for result, row in zip(results, rows):
route = result["routes"][row["mode"]]
body = {**row, **{k: route[k] for k in ("planned", "nodes", "curves")}}
await cursor.execute(
"UPDATE plan_route_years SET route_json = %s, fingerprint = %s "
"WHERE project_id = %s AND year = %s",
(
json.dumps(body, ensure_ascii=False),
result["fingerprint"],
str(project_id),
row["year"],
),
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return {"status": "success", "chain": rows}