Files
Aislo/B06_Section/B06_Section_Router_Stations.py
T
eomsangdonandClaude Opus 5.5 dfd7427c46 feat(b06): 횡단도 바로 반영 — 새로 놓거나 옮긴 관 · 구조물의 횡단 카드를 [저장] 전에 보임 (273-2)
- 서버: 읽기 전용 미리 뜨기 POST /section/preview-stations — 로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀 · 저장과 같은 길(sample_structure_sections · compute_default_designs crosses)
- 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(B06_Section_Section_Drafts) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시
- [저장]은 지금처럼 서버가 같은 계산으로 정본에 씀 — 미리 본 카드와 같은 값
- 알림 글: 횡단도는 바로 보이고 세부 배수유역만 저장 뒤 다시 셈
- 시험: test_273_2 새로 · 177-3 헬퍼에 revealPick 자리 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:48:32 +09:00

431 lines
19 KiB
Python

"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯).
무엇이 문제였나
측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이다. 관을 저장하는
`PUT /drainage/pipe-points` 는 관 파일과 유역만 쓰고 측점을 다시 만들지 않는다.
⇒ **관을 나중에 놓거나 옮기면 그 측점이 안 생긴다.** 그 관은 횡단도에도 안 서고
수량·금액에서 통째로 빠지는데 **아무 말도 안 나온다**(실측: 배수관 넷).
왜 이 방식인가 (세 갈래 중 ㉯)
㉮ 관 저장 뒤 바로 만들기 — 그 엔드포인트에 노선·지표면 인자가 없어 끌어와야 함
㉯ **알리고 [측점 만들기] 단추** — 누를 때만 돌아 비용이 적고 **왜 값이 없는지가 보임**
㉰ 그대로 두기 — 조용히 빠지는 것이 문제라 적어도 알림은 있어야 함
⚠ **지어내지 않는 것** — 지표 샘플링 조건(어느 DTM·어느 방법)이 없으면 만들지 않는다.
조건이 다르면 그 측점만 다른 지표에서 뽑혀 **옆 측점과 지반고가 어긋난다.** 조건은
노선 확정 때 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다.
"""
from __future__ import annotations
import asyncio
import logging
import math
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import SAME_STATION_SNAP_M
from B05_Profile.B05_Profile_Repository import (
get_latest_route,
get_surface_crs_epsg,
update_longitudinal_grade_summary,
)
from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
#: 「그 자리에 측점이 있다」고 볼 거리(m) — 같은 측점 폭(213-1 · 반올림 차이 0.006m) 한 벌(219-1).
#: 넓으면 조금 옮긴 관(769.28 → 769.38)을 「측점 있음」으로 세어 측점이 옛 자리에 남음(192-1).
STATION_MATCH_TOLERANCE_M = SAME_STATION_SNAP_M
SNAPSHOT_MISSING = (
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
" 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남"
)
async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None:
"""이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다.
① 노선 [확정] 때 남긴 `B06_Section/sampling.json` — **그때 실제로 쓴 조건**이라 1순위.
② 없으면 1단계(지표 확정) 저장값 — B06 화면 `context` 가 쓰는 그 값이라 같은 조건이다.
옛 프로젝트는 ①이 없으므로 이 길이 없으면 단추가 영영 안 돈다.
"""
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
snapshot = load_sampling_snapshot(project_root)
params = await run_with_connection(get_surface_confirmation_params, str(project_id))
surface = (params or {}).get("source_filter"), (params or {}).get("method")
# 설계 지형이 바뀌었으면(B04 재확정 · 노선 [확인] 지형 바꿈 PLAN 133-2) ①은 옛 지형 — 버림.
if snapshot is not None and (
not all(surface)
or (snapshot["filter_key"], snapshot["method"], bool(snapshot.get("smooth")))
== (*surface, bool(params.get("smooth")))
):
return snapshot
if not params or not params.get("source_filter") or not params.get("method"):
return None
return {
"filter_key": params["source_filter"],
"method": params["method"],
"smooth": bool(params.get("smooth")),
"surface_model_id": None,
"source": "stage1",
}
def _planted(project_root: Path, route_data_path: str) -> list[tuple[float, str]]:
"""관 · 구조물 정본이 심는 구조물 측점 가운데 **노선 안**(세울 수 있는 것)만."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
polyline = _load_route_polyline(project_root, route_data_path)
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
# 노선 밖(0 미만 · 끝 넘음) = 측점을 못 세움 — 판정에서 뺌(219-1 · 길이 모르면 끝은 안 봄)
end_m = sum(math.dist(a[:2], b[:2]) for a, b in zip(polyline, polyline[1:]))
return [
(float(chainage), label)
for chainage, label in extras
if float(chainage) >= -STATION_MATCH_TOLERANCE_M
and (end_m <= 0 or float(chainage) <= end_m + STATION_MATCH_TOLERANCE_M)
]
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
planted = _planted(project_root, route_data_path)
if not planted:
return []
existing = _station_chainages(project_root)
missing = [
{"chainage_m": round(value, 3), "label": label}
for value, label in planted
if all(abs(value - other) > STATION_MATCH_TOLERANCE_M for other in existing)
]
return sorted(missing, key=lambda item: item["chainage_m"])
def _station_chainages(project_root: Path) -> list[float]:
"""종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록."""
import json
folder = project_root / "B06_Section" / "longitudinal"
values: list[float] = []
for path in sorted(folder.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
chainage = station.get("chainage_m")
if isinstance(chainage, (int, float)):
values.append(float(chainage))
return values
def _stale_marks(project_root: Path, route_data_path: str) -> int:
"""종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음."""
import json
planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)}
standing: set[float] = set()
for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
if station.get("kind") == "irregular" or station.get("structure"):
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
return len(standing ^ planted)
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
async def _load(connection):
stored = await get_project_storage_relative_path(connection, project_id)
route = await get_latest_route(connection, project_id)
return stored, route
stored, route = await run_with_connection(_load)
if not stored or not route:
return None
return Path(resolve_stored_project_path(stored)), route
async def _generation_inputs(
project_id: UUID, route: dict[str, Any], snapshot: dict[str, Any]
) -> tuple[Any, int | None, dict[str, Any] | None]:
"""측점을 뜰 때 쓰는 저장분 — (횡단 옵션, 좌표계 EPSG, 종단 행) · 만들기 · 미리 뜨기 한 벌."""
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_longitudinal_section,
)
async def _load(connection):
options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(
connection, project_id, snapshot.get("surface_model_id")
)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
return options, crs_epsg, longitudinal
return await run_with_connection(_load)
async def _regenerate_structure_stations(
project_id: UUID, project_root: Path, route: dict[str, Any], snapshot: dict[str, Any]
) -> int:
"""구조물 측점 전체를 노선 확정 때와 같은 조건으로 다시 떠 종단 정본에 병합한다. 뜬 수.
⚠ 종단 병합이 비정규 측점을 **통째로 교체**하므로 빠진 것만이 아니라 전체를 뜬다.
"""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Router_Confirm import (
_merge_irregular_into_longitudinal,
_section_options_from_stored,
drop_rows_off_stations,
)
stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot)
def _regenerate() -> tuple[int, dict[str, Any] | None]:
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
stations = generate_irregular_sections(
project_root,
str(route["route_data_path"]),
str(snapshot["filter_key"]),
str(snapshot["method"]),
bool(snapshot.get("smooth")),
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
summary = None
if longitudinal: # 빈 목록이어도 맞춤 — 옛 구조물 측점을 지움(272)
summary = _merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations
)
return len(stations), summary
made, summary = await asyncio.to_thread(_regenerate)
if longitudinal:
await run_with_connection(
drop_rows_off_stations,
route["id"],
project_root,
str(longitudinal["longitudinal_file_path"]),
)
if summary is not None:
# 옮긴 관 자리의 계획선이 다시 셈됨 — DB 등급 요약도 같이(계획선 저장과 같은 길).
await run_with_connection(
lambda connection: update_longitudinal_grade_summary(
connection, route_id=route["id"], grade_summary=summary
)
)
return made
async def ensure_structure_stations(project_id: UUID) -> int:
"""빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎).
옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리
횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다.
만들 수 없으면(노선 · 샘플링 조건 없음 · 빠진 것 없음) 0 — 저장을 막지 않는다.
"""
paths = await _project_paths(project_id)
if paths is None:
return 0
project_root, route = paths
missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"]))
# 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272)
stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"]))
if not missing and not stale:
return 0
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
logger.warning(
"B06 저장: 구조물 측점 %d곳을 못 맞춤 — 샘플링 조건 없음", len(missing) or stale
)
return 0
await _regenerate_structure_stations(project_id, project_root, route, snapshot)
return len(missing) or stale
@router.get("/{project_id}/section/missing-stations")
async def get_missing_stations(project_id: UUID) -> JSONResponse:
"""측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다."""
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(content={"status": "success", "missing": [], "can_create": False})
project_root, route = paths
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
snapshot = await _sampling_conditions(project_id, project_root)
except Exception:
logger.exception("B06 측점 점검 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 점검하지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
"missing": missing,
"can_create": bool(missing) and snapshot is not None,
"reason": "" if snapshot is not None else SNAPSHOT_MISSING,
}
)
@router.post("/{project_id}/section/missing-stations")
async def create_missing_stations(project_id: UUID) -> JSONResponse:
"""빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다.
⚠ 구조물 측점 전체를 다시 뜬다 — 종단 병합이 비정규 측점을 **통째로 교체**하므로
빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다.
"""
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
return JSONResponse(
status_code=409,
content={"status": "error", "message": SNAPSHOT_MISSING},
)
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
if not missing:
return JSONResponse(content={"status": "success", "created": 0, "missing": []})
made = await _regenerate_structure_stations(project_id, project_root, route, snapshot)
except Exception:
logger.exception("B06 측점 만들기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 만들지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
# 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다.
"created": len(missing),
"regenerated": made,
"missing": missing,
}
)
#: 한 번에 미리 뜨는 자리 수 상한 — 화면 초안 한 벌이면 충분 · 큰 요청으로 서버를 붙잡지 않게.
PREVIEW_MAX_STATIONS = 60
class PreviewStationsRequest(BaseModel):
"""미리 뜰 자리(누가거리 m) — 노선 밖 · 숫자 아닌 값은 서버가 버림."""
model_config = ConfigDict(extra="forbid")
chainages: list[float] = Field(default_factory=list, max_length=PREVIEW_MAX_STATIONS)
@router.post("/{project_id}/section/preview-stations")
async def preview_structure_sections(
project_id: UUID, request: PreviewStationsRequest
) -> JSONResponse:
"""초안 자리(놓거나 옮긴 관 · 구조물)의 횡단을 **미리 떠서 돌려만 준다**(273-2 · 요구 168).
파일 · DB 에 아무것도 안 쓴다 — 화면이 캐시에 얹어 B06 카드를 [저장] 전에 보이고, [저장] 때
서버가 같은 길(`sample_structure_sections` · `compute_default_designs`)로 다시 만들어 정본에 씀.
샘플링 조건은 측점 만들기와 같다(없으면 지어내지 않고 409).
"""
from B05_Profile.B05_Profile_Engine_Sections import sample_structure_sections
from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored
from B06_Section.B06_Section_Router_Design import compute_default_designs
chainages = sorted({round(c, 3) for c in request.chainages if math.isfinite(c) and c >= 0})
if not chainages:
return JSONResponse(content={"status": "success", "cross_sections": []})
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
return JSONResponse(
status_code=409, content={"status": "error", "message": SNAPSHOT_MISSING}
)
stored_options, crs_epsg, longitudinal = await _generation_inputs(
project_id, route, snapshot
)
if not longitudinal:
return JSONResponse(content={"status": "success", "cross_sections": []})
def _preview() -> list[dict[str, Any]]:
_stations, crosses = sample_structure_sections(
project_root,
str(route["route_data_path"]),
str(snapshot["filter_key"]),
str(snapshot["method"]),
bool(snapshot.get("smooth")),
extra_stations=tuple((chainage, "") for chainage in chainages),
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
wanted = set(chainages)
picked = {
round(float(cross["chainage_m"]), 3): cross
for cross in crosses
if round(float(cross["chainage_m"]), 3) in wanted
}
designs = compute_default_designs(
project_root,
str(longitudinal["longitudinal_file_path"]),
list(picked),
crosses=picked,
)
for chainage, design in designs:
picked[round(chainage, 3)]["design"] = design
return list(picked.values())
sections = await asyncio.to_thread(_preview)
except Exception:
logger.exception("B06 미리 뜨기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단을 미리 뜨지 못했습니다."},
)
return JSONResponse(content={"status": "success", "cross_sections": sections})