feat(B03): 계획노선 shapefile 의 CSV 정본화 + LAS 없는 도엽 서피스 좌표계 교정

- 자동설계 체인이 초기값 스냅샷에 `planned_route.csv` 를 함께 남김. 값은 체인이
  이미 만든 정점(사업지 좌표계·트림·조밀화 후)이라 재계산 없음.
- `load_design_route()` 는 surface_params 가 주어진 호출에 한해 그 CSV 정본을 읽음.
  트림 전 원본이 필요한 호출(도엽 범위)은 종전 경로 유지. 지표면·노선이 바뀌면
  `discard_initial_snapshot()` 이 폴더째 지워 경로가 저절로 닫힘.
- `run_sheet_surface_analysis()` 가 노선을 원본 좌표계로 읽어 도엽 서피스를 딴 자리에
  만들던 문제 교정 — `load_design_route()` 로 창구 통일, 도엽 확보 PRJ 탐색도 지형
  PRJ 폴더로 교정. shapefile 노선(5179) + 지형 PRJ(5176) 조합에서 트림이 노선을
  통째로 지우던 원인.

검증: tmp/tests 158 passed(신규 4건 포함), 공용 브라우저 실사용 경로로 용화 shapefile
한 벌 업로드 → 자동 체인 완주 → CSV 정본 643점, 원본 경로와 최대 좌표차 0.00007m.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 08:17:21 +09:00
co-authored by Claude Opus 5
parent e065e5701e
commit 56b9bf5280
4 changed files with 63 additions and 11 deletions
+1 -1
View File
@@ -263,7 +263,7 @@ async def run_auto_design_chain(
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
try:
async with pool.acquire() as connection:
await save_initial_snapshot(connection, project_root, route_id)
await save_initial_snapshot(connection, project_root, route_id, points)
except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
@@ -510,7 +510,8 @@ def run_sheet_surface_analysis(
반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환).
"""
from common_util.common_util_route_geometry import read_planned_route
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_route_geometry import load_design_route
def _report(percent: int, stage: str, message: str) -> None:
if on_progress is not None:
@@ -522,10 +523,14 @@ def run_sheet_surface_analysis(
processed_dir.mkdir(parents=True, exist_ok=True)
models_dir.mkdir(parents=True, exist_ok=True)
planned = read_planned_route(route_csv_path)
# 노선을 **사업지(.prj) 좌표계로 옮긴 뒤** 도엽을 뜬다. 원본 좌표 그대로 뜨면 노선이
# 5179, 지표면이 5176처럼 갈려 설계 계통(`load_design_route`)이 재투영한 노선이 지표면
# 밖으로 나가고 트림이 노선을 통째로 지운다(2026-09-03 실측: shapefile 노선 + LAS 없는
# 설계). LAS 경로·`build_sheet_surface_from_route`와 같은 창구를 쓰는 것이 요지다.
planned = load_design_route(project_root)
if planned is None or len(planned.vertices) < 2:
raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}")
epsg = planned.epsg or 5186
crs = planned.crs_input or project_epsg_from_prj(project_root)
route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64)
bounds_dict = {
@@ -545,16 +550,15 @@ def run_sheet_surface_analysis(
project_root,
processed_dir,
bounds_dict,
route_csv_path.parent,
# 노선 세트 폴더의 PRJ는 노선 좌표계다 — 지형 PRJ를 고르게 지형 폴더를 준다.
project_root / "B03_FileInput" / "input" / "prj",
rebuild=False,
default_epsg=f"EPSG:{epsg}",
default_epsg=crs,
report=_report,
)
_report(70, "surface_model", "도엽등고선 3D 서피스 생성 중")
models = build_sheet_surface_model(
project_root, processed_dir, models_dir, route_xy, f"EPSG:{epsg}"
)
models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, crs)
if not models:
raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.")
+33 -2
View File
@@ -9,6 +9,7 @@ CLAUDE.md 5장(조작·데이터 흐름 정책)의 **초기값** 층이다. 자
`pipe_points.json` 편집분이 그대로 남아 초기값과 다른 결과가 나온다(2026-08-29).
"""
import csv
import json
import shutil
from pathlib import Path
@@ -19,6 +20,10 @@ import aiomysql
# 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다.
SNAPSHOT_DIRNAME = "initial_snapshot"
_DB_DUMP_NAME = "db.json"
# 설계가 쓰는 계획노선 정본 — shapefile로 온 노선도 여기서는 CSV 한 벌이다.
# 사업지(.prj) 좌표계로 옮기고 지표면 밖을 잘라 조밀화까지 끝낸 값이라, 설계 계통은
# 이 파일만 읽으면 매번 같은 노선을 본다(2026-09-03 사용자 확정).
DESIGN_ROUTE_CSV_NAME = "planned_route.csv"
# 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용).
DESIGNING_LOCK_NAME = "initial_design.lock"
# 초기 설계 체인이 실패로 끝났음을 남기는 마커.
@@ -40,6 +45,11 @@ def snapshot_dir(project_root: Path) -> Path:
return Path(project_root) / SNAPSHOT_DIRNAME
def design_route_csv_path(project_root: Path) -> Path:
"""설계용 계획노선 CSV 정본의 자리."""
return snapshot_dir(Path(project_root)) / DESIGN_ROUTE_CSV_NAME
def designing_lock_path(project_root: Path) -> Path:
"""초기 설계 체인이 도는 동안만 존재하는 마커.
@@ -158,14 +168,24 @@ def _is_json_native(value: Any) -> bool:
async def save_initial_snapshot(
connection: aiomysql.Connection, project_root: Path, route_id: int
connection: aiomysql.Connection,
project_root: Path,
route_id: int,
design_route_points: list[dict[str, float]] | None = None,
) -> None:
"""자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다."""
"""자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다.
`design_route_points`는 체인이 이미 만들어 둔 계획노선 정점(사업지 좌표계·트림·조밀화
후)이다. 받으면 CSV 정본으로 함께 남긴다 — 노선이 shapefile로 왔더라도 설계 계통이
읽는 것은 이 CSV 한 벌이다.
"""
root = Path(project_root)
target = snapshot_dir(root)
if has_initial_snapshot(root):
return
target.mkdir(parents=True, exist_ok=True)
if design_route_points:
_write_design_route_csv(target / DESIGN_ROUTE_CSV_NAME, design_route_points)
for tree in _FILE_TREES:
_copy_tree(root / tree, target / tree.replace("/", "__"))
@@ -181,6 +201,17 @@ async def save_initial_snapshot(
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
def _write_design_route_csv(path: Path, points: list[dict[str, float]]) -> None:
"""계획노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것으로."""
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(("sequence", "x", "y"))
writer.writerows(
(index, round(point["x"], 4), round(point["y"], 4))
for index, point in enumerate(points)
)
def wipe_edited_masters(project_root: Path) -> list[str]:
"""사용자 편집 정본을 걷어낸다 — 재계산으로 **진짜 초기값**을 만들기 위한 사전 정리.
+17
View File
@@ -227,12 +227,29 @@ def load_design_route(
주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다.
"""
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_initial_snapshot import design_route_csv_path
from config.config_system import (
ROUTE_DIRECT_LINK_CELL_FACTOR,
ROUTE_GRID_RES_M,
ROUTE_PLANNED_DENSIFY_SAFETY,
)
# 체인이 남긴 CSV 정본이 있으면 그것이 설계 노선이다 — 좌표계 변환·트림·조밀화가 이미
# 끝난 값이라 다시 하지 않는다(노선이 shapefile로 왔어도 여기서는 CSV 한 벌이다).
# 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로
# 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 —
# surface_params 없음)은 여기를 타지 않는다.
if surface_params:
master = design_route_csv_path(project_root)
if master.is_file():
stored = read_planned_route_csv(master)
if stored is not None and len(stored.vertices) >= 2:
return replace_vertices(
stored,
[(v.x, v.y) for v in stored.vertices],
crs_input=project_epsg_from_prj(project_root),
)
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
planned = read_planned_route(route_file) if route_file else None
if planned is None or len(planned.vertices) < 2: