From e83171b2b35988619d417e3489fffa76c87fa412 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:25:20 +0900 Subject: [PATCH] =?UTF-8?q?refactor(=EA=B3=B5=ED=86=B5):=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=A2=8C=ED=91=9C=EA=B3=84=20=ED=8F=B4=EB=B0=B1?= =?UTF-8?q?=EC=9D=84=20=EC=B0=BD=EA=B5=AC=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20+=20=ED=8A=B8=EB=A6=BC=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=A7=84=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 폴백 사다리가 네 곳에 서로 다른 모양으로 흩어져 있었고, 그중 배수유역 라우터 `_route_center_lonlat` 은 노선 CSV 의 `crs_epsg` **라벨**을 그대로 변환에 썼다(라벨과 실좌표계가 다른 사례 실측 — 2026-09-01 용화 라벨 5179 / 실제 5176). `common_util_crs.resolve_project_crs()` 신설 — 사다리는 ① 노선 crs_input ② 파일 라벨 (원본 좌표를 읽는 자리만) ③ 지형 PRJ(작업 좌표계 정본) ④ DB epsg ⑤ EPSG:5186. 작업 좌표계는 **지형 PRJ 우선으로 확정**(2026-09-03 사용자 결정 — 서피스 격자가 모델좌표의 주인이라 현행 유지). `project_epsg_from_prj()` 도 이 창구로 위임. 더해 지표면 트림이 노선을 통째로 지울 때 노선·지표면 bbox 를 함께 로그에 남긴다 — "겹치지 않습니다" 만으로는 좌표계 문제인지 측량 범위 문제인지 갈리지 않았다. 검증 — `tmp/tests/test_project_crs_resolution.py` 5건 신설(사다리 4·진단 1), 전체 375 passed·17 skipped, ruff format 무변경. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Engine_Extent.py | 10 ++-- .../B04_PreProcess_Router_Inflow.py | 7 +-- .../B04_PreProcess_Router_Watershed.py | 11 ++++- common_util/common_util_crs.py | 48 +++++++++++++++++++ common_util/common_util_drainage_context.py | 5 +- common_util/common_util_route_geometry.py | 33 +++++++++++++ 6 files changed, 98 insertions(+), 16 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Extent.py b/B04_PreProcess/B04_PreProcess_Engine_Extent.py index 2e5e931e..98865116 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Extent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Extent.py @@ -147,15 +147,11 @@ def project_epsg_from_prj(project_root: Path) -> str: """프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186). PRJ가 둘 이상 올라오므로(노선 세트 + 지형) 지형 PRJ를 고른다 — `find_project_prj`. + 판정은 작업 좌표계 창구(`resolve_project_crs`) 하나로 모았다(2026-09-03). """ - from common_util.common_util_crs import find_project_prj + from common_util.common_util_crs import resolve_project_crs - from .B04_PreProcess_Engine_VWorld import get_epsg_from_prj - - prj_path = find_project_prj(project_root) - if prj_path is not None: - return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) - return "EPSG:5186" + return resolve_project_crs(project_root) def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: diff --git a/B04_PreProcess/B04_PreProcess_Router_Inflow.py b/B04_PreProcess/B04_PreProcess_Router_Inflow.py index 9d72165a..98ba38f6 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Inflow.py +++ b/B04_PreProcess/B04_PreProcess_Router_Inflow.py @@ -77,19 +77,16 @@ async def _resolve_epsg(project_id: UUID, stored_path: str) -> str: `crs_epsg` 열은 표시용 라벨이라 그 값을 쓰면 좌표가 딴 곳으로 간다 (2026-09-01 실측: 라벨 5179, 실제 5176 — 유입 폴리곤이 1,500km 밖에 찍혔다). """ - from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_crs import resolve_project_crs project_root = Path(resolve_stored_project_path(stored_path)) planned = load_design_route(project_root) if planned is not None and planned.crs_input: return planned.crs_input - prj_crs = project_epsg_from_prj(project_root) - if prj_crs: - return prj_crs pool = get_db_pool() async with pool.acquire() as connection: epsg = await get_surface_crs_epsg(connection, project_id, 0) - return f"EPSG:{epsg or 5186}" + return resolve_project_crs(project_root, db_epsg=epsg) def _collect_inflow( diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index c5bf9233..c6faa675 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -42,6 +42,7 @@ from common_util.common_util_route_geometry import ( load_design_route, read_planned_route, ) +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_wamis_rainfall import ( @@ -190,7 +191,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: ) # 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다. - source_crs = planned.crs_input or f"EPSG:{epsg or 5186}" + source_crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=epsg) to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) @@ -257,7 +258,13 @@ def _route_center_lonlat(stored_path: str, fallback_epsg: int | None) -> tuple[f if planned is None or not planned.vertices: return None middle = planned.vertices[len(planned.vertices) // 2] - source_crs = f"EPSG:{planned.epsg or fallback_epsg or 5186}" + # 여기 좌표는 **원본 파일 그대로**(트림 전)라 파일이 밝힌 좌표계로 읽는다 — 창구 사다리. + source_crs = resolve_project_crs( + Path(resolve_stored_project_path(stored_path)), + route_crs_input=planned.crs_input, + file_label_epsg=planned.epsg, + db_epsg=fallback_epsg, + ) lon, lat = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True).transform( middle.x, middle.y ) diff --git a/common_util/common_util_crs.py b/common_util/common_util_crs.py index fb56efbe..ac511ef8 100644 --- a/common_util/common_util_crs.py +++ b/common_util/common_util_crs.py @@ -209,3 +209,51 @@ def crs_input_from_prj(prj_text: str) -> str | None: else: logger.warning("PRJ 좌표계의 EPSG 라벨을 찾지 못함 — 파일 WKT로 변환: %s", crs.name) return text + + +def project_prj_crs(project_root: Path) -> str | None: + """프로젝트 **작업 좌표계** — 지형 PRJ 원문. PRJ가 없으면 None. + + 작업 좌표계는 지형 PRJ 로 확정(2026-09-03 사용자 결정, 종전 동작 유지). 서피스 격자가 + 모델좌표의 주인이고 노선은 `load_design_route()` 가 이 좌표계로 옮겨 오기 때문이다. + 노선 shapefile 은 다른 좌표계로 들어온다(실측 2026-08-31 — 노선 UTM-K / 지형 동부원점). + + 기본값을 섞지 않는다 — 사다리(`resolve_project_crs`)가 다음 근거로 내려갈 수 있어야 한다. + """ + prj_path = find_project_prj(project_root) + if prj_path is None: + return None + from B04_PreProcess.B04_PreProcess_Engine_VWorld import get_epsg_from_prj + + return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + + +def resolve_project_crs( + project_root: Path, + *, + route_crs_input: str | None = None, + file_label_epsg: int | None = None, + db_epsg: int | None = None, +) -> str: + """작업 좌표계를 정하는 **단일 창구** — pyproj 입력 문자열(`EPSG:n` 또는 WKT 원문). + + 사다리(위가 이김): + ① `load_design_route()` 가 돌려준 노선 `crs_input` — 이미 작업 좌표계로 맞춰 둔 값 + ② `file_label_epsg` — **원본 파일 좌표를 그대로 해석할 때만** 넘긴다(노선 CSV 의 + `crs_epsg` 열). 그 자리에서는 파일이 스스로 밝힌 좌표계가 유일한 근거다. + ③ 지형 PRJ(`project_prj_crs`) — 작업 좌표계 정본 + ④ DB `surface_models.crs_epsg` + ⑤ 중부원점 `EPSG:5186` — 최후 폴백 + + **변환이 끝난 좌표를 다룰 때는 ②를 넘기지 않는다.** 그 라벨은 실좌표계와 다른 사례가 + 실측됐다(2026-09-01 용화 — 라벨 5179 / 실제 5176). 이 창구를 두기 전에는 같은 사다리가 + 네 곳에 서로 다른 모양으로 흩어져 있었다. + """ + if route_crs_input: + return route_crs_input + if file_label_epsg: + return f"EPSG:{file_label_epsg}" + prj_crs = project_prj_crs(project_root) + if prj_crs: + return prj_crs + return f"EPSG:{db_epsg or 5186}" diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index bb71200f..2d023038 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -33,6 +33,7 @@ from common_util.common_util_route_geometry import ( load_design_route, ) from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler @@ -99,8 +100,8 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non ) # 노선을 실제로 담고 있는 좌표계를 쓴다 — `load_design_route()`가 .prj 좌표계로 - # 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다. - crs = planned.crs_input or f"EPSG:{planned.epsg or db_epsg or 5186}" + # 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다(라벨은 안 씀). + crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=db_epsg) transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True) return ( DrainageContext( diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index eb02f0d2..b467425e 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -277,7 +277,10 @@ def load_design_route( except (FileNotFoundError, KeyError, OSError) as exc: logger.warning("설계 노선: 지표면을 열지 못해 트림을 건너뜁니다 — %s", exc) else: + before = points points = trim_route_to_surface(points, sampler) + if len(points) < 2: + _log_trim_wipeout(before, sampler, target_crs) points = densify_route( points, ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, @@ -308,6 +311,36 @@ def replace_vertices( ) +def _log_trim_wipeout(points: list[tuple[float, float]], sampler: Any, target_crs: str) -> None: + """트림이 노선을 통째로 지운 이유를 **수치로** 남긴다. + + 노선과 지표면의 좌표계가 어긋나면 트림 결과가 빈 목록이 되는데, 그때 로그가 + "겹치지 않습니다" 뿐이라 좌표계 문제인지 범위 문제인지 갈리지 않았다(2026-09-03 실사고: + 도엽 서피스가 노선 원본 좌표계로 만들어져 노선이 통째로 지워짐). 두 bbox 를 함께 찍어 + 한 줄로 판별되게 한다 — 겹침이 0이면 좌표계, 일부 겹치면 측량 범위 문제다. + """ + xs = [x for x, _ in points] + ys = [y for _, y in points] + grid_x = getattr(sampler, "x", None) + grid_y = getattr(sampler, "y", None) + surface = ( + f"[{float(min(grid_x)):.0f}~{float(max(grid_x)):.0f}, " + f"{float(min(grid_y)):.0f}~{float(max(grid_y)):.0f}]" + if grid_x is not None and grid_y is not None and len(grid_x) and len(grid_y) + else "미상" + ) + logger.warning( + "설계 노선: 지표면 트림 결과가 비었습니다 — 노선 bbox [%.0f~%.0f, %.0f~%.0f] / " + "지표면 bbox %s (작업 좌표계 %s). 두 범위가 전혀 안 겹치면 좌표계 불일치입니다.", + min(xs), + max(xs), + min(ys), + max(ys), + surface, + target_crs, + ) + + def trim_route_to_surface( points: list[tuple[float, float]], sampler: Any,