"""계산 자리 「서버」 창구(PLAN 511-A · 요구 320-4) — 조작 중 계산을 서버가 같은 TS 로. POST /api/projects/{id}/calc {run: [id…], inputs: {…}} → {results, ms, wall_ms} 브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름 (`design_calc_server.ts`). 권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`. 파일 경로 = 서버가 프로젝트 id 로 정함(그 storage 안 DTM 격자) · 입력에 경로 칸 = 400. 실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) · 정본 저장 길은 이 창구를 안 씀. """ import asyncio import json from uuid import UUID from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from common_util.common_util_calc_runner import SERVER_ONLY_IDS, run_calc_served from config.config_db import get_db_pool router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"]) #: 본문 상한 — 종횡단 상세 한 벌(1.3~1.5 MB) 여유 · 넘으면 413. MAX_BODY = 8 * 1024 * 1024 def _error(status: int, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"status": "error", "message": message}) async def _server_grid(project_id: UUID, inputs: dict) -> str | None: """파일 경로는 브라우저 값을 안 씀 — 단계 격자 = `{model_id, smooth, design}` → 이 프로젝트 storage 안 DTM 격자 경로(서버가 정함) · 경로 칸(`stages.grid.path` · `stations.grid`) = 거절.""" stations = inputs.get("stations") if isinstance(stations, dict) and "grid" in stations: return "격자 경로는 서버가 정함" stages = inputs.get("stages") grid = stages.get("grid") if isinstance(stages, dict) else None if grid is None: return None if not isinstance(grid, dict) or "path" in grid: return "격자 경로는 서버가 정함 — {model_id, smooth, design}" model_id, design = grid.get("model_id"), grid.get("design", "las") if ( not isinstance(model_id, int) or isinstance(model_id, bool) or design not in ("las", "sheet") ): return "격자 = {model_id: 정수, smooth, design: las | sheet}" from B05_Profile.B05_Profile_Router_Terrain import dtm_grid_npz from common_util.common_util_surface_sampler import dtm_grid_file async with get_db_pool().acquire() as connection: try: npz = await dtm_grid_npz(connection, project_id, model_id, bool(grid.get("smooth"))) except LookupError as exc: return str(exc) path = await asyncio.to_thread(dtm_grid_file, npz) stages["grid"] = {"path": str(path), "design": design} return None @router.post("/{project_id}/calc", response_model=None) async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONResponse: declared = request.headers.get("content-length") if declared and declared.isdigit() and int(declared) > MAX_BODY: return _error(413, "계산 입력이 너무 큼") body = b"" async for chunk in request.stream(): body += chunk if len(body) > MAX_BODY: return _error(413, "계산 입력이 너무 큼") try: payload = json.loads(body) except ValueError: return _error(400, "JSON 아님") run = payload.get("run") if isinstance(payload, dict) else None inputs = payload.get("inputs", {}) if isinstance(payload, dict) else None if not isinstance(run, list) or not all(isinstance(i, str) for i in run): return _error(400, "run = id 목록") if not isinstance(inputs, dict): return _error(400, "inputs = 객체") if SERVER_ONLY_IDS.intersection(run): return _error(400, "서버 측점 생성은 창구 밖") problem = await _server_grid(project_id, inputs) if problem: return _error(400, problem) if {"stations.list", "stations.plan"} & set(run) and isinstance(inputs.get("stations"), dict): # 심는 측점 · 카드 이름 표 = 서버 구조물 정의(서버 측점 생성과 같은 표 · 511-B B-2) from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table inputs["stations"]["definition"] = (await asyncio.to_thread(structure_table))[1] try: answer = await asyncio.to_thread(run_calc_served, run, inputs) except ValueError as exc: return _error(400, str(exc)) if answer is None: return _error(503, "서버 계산을 못 돌림") if "error" in answer: return _error(422, str(answer["error"])) return {"status": "success", "project_id": str(project_id), **answer}