From b014c4c1ecc4357bcb712b121522724c520fcd49 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 1 Sep 2026 15:53:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EB=85=B8=EC=84=A0=20=EC=9B=90?= =?UTF-8?q?=EC=B2=9C=EC=9D=84=20=ED=95=9C=20=EA=B3=B3=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=9C=BC=EA=B3=A0=20=EB=8F=84=EC=97=BD=20=EC=84=9C?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=EB=A5=BC=20=EA=B8=B0=EB=B3=B8=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A7=8C=20=EB=A7=8C=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 용화 자동 체인이 끝까지 돌았는데 종단 계획선이 PVI 3개 직선으로 나왔다. 관 22개 측점이 92.6~2044.9m 로 트림 전 원본(2,136m) 기준인데 확정 노선은 1,070.4m 라 21개가 노선 밖이었다. 좌표계도 갈려 있었다 — 관은 EPSG 5179(노선 파일), 노선은 5176(프로젝트)에 저장돼 누가거리만 우연히 맞물려 있었다. 원인은 계획노선을 읽는 곳이 흩어져 있는 것이다. 트림·조밀화를 체인 한 곳에만 넣었더니 배수유역·유입·도엽은 원본을 그대로 읽었다. - load_design_route() 신규 — 읽기·좌표계 변환·트림·조밀화를 한 곳에서 끝낸다. 설계 계통(체인·배수유역)은 전부 이 함수를 지난다. - Router_Watershed 도 이 함수를 쓴다. 좌표계가 프로젝트 기준으로 통일돼 관과 노선이 같은 공간에 놓인다. - 도엽 서피스는 SHEET_SURFACE_AUTO_METHODS(기본 laplace 1종)만 자동 생성한다. 여섯 방식을 매번 만들면 WF1 891초 중 655초를 여기서 쓴다. 나머지는 관리자가 화면에서 고를 때 만든다. Router_Inflow 는 노선을 EPSG 라벨용으로만 쓰고 기하를 안 써서 제외했다. Engine_Extent(배경 지도 범위)도 제외 — 지도는 트림 전 전 구간을 덮어야 사용자가 측량 범위 밖을 볼 수 있다. 실측(용화 route 120): 배수유역 노선 2,136m -> 1,070m 격자 889x1435 -> 545x831 1차 영역 538,574㎡ -> 263,170㎡ 관 좌표계 EPSG 5179 -> 5176 관 22개(10개가 노선 밖) -> 11개 전부 노선 안 종단 PVI 3개 -> 13개, 종곡선 1 -> 11, 불균형 97.0% -> 86.2% PVI 측점이 관 측점과 일치한다(반올림 오차 제외). Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_Chain.py | 73 +++-------------- B04_PreProcess/B04_PreProcess_Engine.py | 11 ++- .../B04_PreProcess_Engine_SheetSurface.py | 13 ++- .../B04_PreProcess_Router_Watershed.py | 15 +++- common_util/common_util_drainage_context.py | 16 ++-- common_util/common_util_route_geometry.py | 79 +++++++++++++++++++ config/config_system.py | 8 ++ 7 files changed, 138 insertions(+), 77 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index cd4247bc..de3a99b7 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -28,70 +28,23 @@ logger = logging.getLogger(__name__) def _planned_route_points_in_project_crs( project_root: Path, surface: dict[str, Any] | None = None ) -> list[dict[str, float]] | None: - """계획노선 파일(CSV·shapefile)을 읽어 프로젝트 좌표계(m) 점 목록으로 돌려준다. + """설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. - B04 `/planned-route` 조회와 같은 규칙 — 파일이 제 좌표계를 싣고 있고 - 프로젝트 좌표계와 다르면 한 번 옮긴다. - - `surface`(확정 필터·방식·스무딩)를 받으면 지표면이 덮지 못하는 구간을 잘라 낸다. - 라이다 측량이 노선 전 구간을 덮지 않는 현장이 있다(용화: 2,136m 중 1,400m). - 자르지 않으면 체인이 서피스 밖 정점에서 끊긴다. + 읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도 + 같은 함수를 쓰므로 여기만 트림되는 일이 없다. """ - from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj - from common_util.common_util_route_geometry import ( - densify_route, - find_planned_route_file, - read_planned_route, - trim_route_to_surface, - ) - from config.config_system import ( - ROUTE_DIRECT_LINK_CELL_FACTOR, - ROUTE_GRID_RES_M, - ROUTE_PLANNED_DENSIFY_SAFETY, - ) + from common_util.common_util_route_geometry import load_design_route - 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: + planned = load_design_route(project_root, surface) + if planned is None: + if surface: + logger.warning( + "자동 설계 체인 중단(설계 노선 없음): 계획노선을 읽지 못했거나 라이다 측량" + " 범위와 겹치지 않습니다 — %s", + project_root.name, + ) return None - target_epsg = project_epsg_from_prj(project_root) - points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices] - source_epsg = planned.crs_input or target_epsg - if source_epsg.upper() != target_epsg.upper(): - from pyproj import Transformer - - transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True) - points = [transformer.transform(x, y) for x, y in points] - - if surface: - from common_util.common_util_surface_sampler import build_surface_sampler - - try: - sampler = build_surface_sampler( - project_root / "B04_PreProcess" / "models", - str(surface["source_filter"]), - str(surface["method"]), - bool(surface["smooth"]), - ) - except (FileNotFoundError, KeyError, OSError) as exc: - logger.warning("자동 설계 체인 노선 트림 건너뜀 — 지표면을 열지 못했습니다: %s", exc) - else: - points = trim_route_to_surface(points, sampler) - # 예정노선은 정점이 성기다(용화 평균 17.6m). 그대로 넘기면 구간마다 격자 - # 탐색을 타고 급경사 현에서 끊긴다. 문턱 아래로 간격만 좁히면 계획노선이 - # 손대지 않은 채 채택된다 — 같은 직선 위에 점을 더 찍는 것이라 형상 불변. - points = densify_route( - points, - ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, - ) - if len(points) < 2: - logger.warning( - "자동 설계 체인 중단(노선이 지표면 밖): 계획노선과 라이다 측량 범위가" - " 겹치지 않습니다 — %s", - project_root.name, - ) - return None - return [{"x": x, "y": y} for x, y in points] + return [{"x": v.x, "y": v.y} for v in planned.vertices] async def _prepare_drainage_pipes_and_reprofile( diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 15025307..f1e48d4b 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -23,7 +23,11 @@ from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_mode from B04_PreProcess.B04_PreProcess_Engine_Structurize import structurize_las from common_util.common_util_atomic import atomic_write_npz from common_util.common_util_json import atomic_write_json -from config.config_system import SURFACE_GROUND_RATIO_WARN, build_surface_model_config +from config.config_system import ( + SHEET_SURFACE_AUTO_METHODS, + SURFACE_GROUND_RATIO_WARN, + build_surface_model_config, +) logger = logging.getLogger(__name__) @@ -262,7 +266,10 @@ def run_surface_analysis( build_sheet_surface_from_route, ) - sheet_models = build_sheet_surface_from_route(project_root, processed_dir, models_dir) + # 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때. + sheet_models = build_sheet_surface_from_route( + project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS) + ) except Exception as exc: logger.warning("도엽등고선 서피스 생성 실패: %s", exc) diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index d2d0ee19..dd52ea62 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -446,9 +446,16 @@ def build_sheet_surface_model( def build_sheet_surface_from_route( - project_root: Path, processed_dir: Path, models_dir: Path + project_root: Path, + processed_dir: Path, + models_dir: Path, + methods: list[str] | None = None, ) -> list[dict[str, Any]]: - """B03 업로드 계획노선 CSV를 찾아 방식별 도엽 서피스를 만든다. 없으면 빈 목록.""" + """B03 업로드 계획노선을 찾아 지정한 방식의 도엽 서피스를 만든다. 없으면 빈 목록. + + `methods`를 주지 않으면 `SHEET_SURFACE_METHODS` 전체를 만든다 — 관리자가 화면에서 + 한 방식을 요청할 때 그 목록만 넘긴다. + """ from common_util.common_util_route_geometry import ( find_planned_route_file, read_planned_route, @@ -464,7 +471,7 @@ def build_sheet_surface_from_route( return [] route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) return build_sheet_surface_model( - project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186 + project_root, processed_dir, models_dir, route_xy, planned.epsg or 5186, methods ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index d77acbc9..c5bf9233 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -39,9 +39,11 @@ from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg from common_util.common_util_route_geometry import ( StructureCandidate, find_planned_route_file, + load_design_route, read_planned_route, ) 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 ( build_rainfall_table, ensure_contour_cache, @@ -160,30 +162,35 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: 노선은 **B03에 업로드된 계획 노선 파일**에서 읽는다 — B05의 확정 경로가 아니다. 배수유역 분석은 노선 설계보다 먼저 끝나 있어야 하기 때문이다(2026-07-31 사용자 지시). + 다만 설계 계통과 **같은 노선**이어야 한다 — `load_design_route()`가 지표면 밖 구간을 + 잘라 프로젝트 좌표계로 돌려준다. 원본을 그대로 쓰면 유역·관이 확정 노선 밖에도 + 찍히고 좌표계마저 갈린다(2026-09-01 실측: 관은 5179, 노선은 5176이었다). """ pool = get_db_pool() async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) epsg = await get_surface_crs_epsg(connection, project_id, 0) + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + project_root = Path(resolve_stored_project_path(stored_path)) route_file = find_planned_route_file(_route_input_dir(stored_path)) if route_file is None: return JSONResponse( status_code=404, content={"status": "error", "message": "계획 노선 파일이 업로드되지 않았습니다."}, ) - planned = read_planned_route(route_file) + planned = await asyncio.to_thread(load_design_route, project_root, surface_params) if planned is None or len(planned.vertices) < 2: return JSONResponse( status_code=400, content={ "status": "error", - "message": f"계획 노선 파일을 읽지 못했습니다: {route_file.name}", + "message": f"계획 노선을 읽지 못했거나 지표면과 겹치지 않습니다: {route_file.name}", }, ) - # 노선 파일이 CRS를 명시하면 그 값을 따른다. 도엽 재투영도 같은 좌표계로 맞춘다. - source_crs = f"EPSG:{planned.epsg or epsg or 5186}" + # 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다. + source_crs = planned.crs_input or f"EPSG:{epsg or 5186}" 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) diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index bce2711f..364704b7 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -30,8 +30,7 @@ from B06_Section.B06_Section_Repository import get_longitudinal_section from common_util.common_util_route_geometry import ( RouteVertex, build_route_vertices, - find_planned_route_file, - read_planned_route, + load_design_route, ) from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile from common_util.common_util_storage import resolve_stored_project_path @@ -82,9 +81,11 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non surface_params = await get_surface_confirmation_params(connection, str(project_id)) project_root = Path(resolve_stored_project_path(stored_path)) - planned = await asyncio.to_thread(_read_planned_route, project_root) + # 설계 계통과 **같은 노선**을 쓴다 — 지표면 밖 구간을 자른 뒤의 노선이다. 원본을 그대로 + # 쓰면 유역·관이 확정 노선 밖에도 찍혀 종단 계획선이 그 관을 버린다(2026-09-01). + planned = await asyncio.to_thread(_read_planned_route, project_root, surface_params) if planned is None or len(planned.vertices) < 2: - return None, "원청 계획노선 CSV를 읽지 못했습니다. B03에서 노선 파일을 확인하세요." + return None, "계획노선을 읽지 못했습니다. B03에서 노선 파일을 확인하세요." sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params) vertices, z_source = await asyncio.to_thread( @@ -111,10 +112,9 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non ) -def _read_planned_route(project_root: Path): - """원청 계획노선 CSV를 찾아 읽는다(파일 접근이라 스레드에서 돈다).""" - path = find_planned_route_file(project_root / _INPUT_SUBDIR) - return read_planned_route(path) if path else None +def _read_planned_route(project_root: Path, surface_params: dict[str, Any] | None = None): + """설계용 계획노선을 읽는다(파일 접근이라 스레드에서 돈다).""" + return load_design_route(project_root, surface_params) def _open_sampler(project_root: Path, surface_params: dict[str, Any]): diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 72cf3687..3dfd7032 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -212,6 +212,85 @@ def find_planned_route_file(input_dir: Path) -> Path | None: return None +def load_design_route( + project_root: Path, surface_params: dict[str, Any] | None = None +) -> PlannedRoute | None: + """설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다. + + 노선을 읽는 곳이 여럿이라(체인·배수유역·유입·도엽) 각자 읽으면 트림이 적용된 곳과 + 안 된 곳이 갈린다. 실제로 그렇게 갈려 관 측점이 트림 전(2,136m) 기준으로 찍히고 + 확정 노선(1,070m)과 어긋나 종단 계획선이 직선으로 나왔다(2026-09-01). + **설계 계통은 전부 이 함수를 지난다.** + + `surface_params`(확정 필터·방식·스무딩)를 주면 지표면이 덮지 못하는 구간을 잘라 내고, + B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다. + 주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다. + """ + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from config.config_system import ( + ROUTE_DIRECT_LINK_CELL_FACTOR, + ROUTE_GRID_RES_M, + ROUTE_PLANNED_DENSIFY_SAFETY, + ) + + 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: + return None + + target_crs = project_epsg_from_prj(project_root) + points = [(float(v.x), float(v.y)) for v in planned.vertices] + source_crs = planned.crs_input or target_crs + if source_crs.upper() != target_crs.upper(): + from pyproj import Transformer + + transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True) + points = [transformer.transform(x, y) for x, y in points] + + if surface_params: + from common_util.common_util_surface_sampler import build_surface_sampler + + try: + sampler = build_surface_sampler( + project_root / "B04_PreProcess" / "models", + str(surface_params["source_filter"]), + str(surface_params["method"]), + bool(surface_params["smooth"]), + ) + except (FileNotFoundError, KeyError, OSError) as exc: + logger.warning("설계 노선: 지표면을 열지 못해 트림을 건너뜁니다 — %s", exc) + else: + points = trim_route_to_surface(points, sampler) + points = densify_route( + points, + ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, + ) + if len(points) < 2: + return None + return replace_vertices(planned, points, crs_input=target_crs) + + +def replace_vertices( + planned: PlannedRoute, points: list[tuple[float, float]], *, crs_input: str | None = None +) -> PlannedRoute: + """XY 목록으로 노선 정점을 갈아 끼우고 누가거리를 다시 센다.""" + vertices: list[RouteVertex] = [] + cumulative = 0.0 + previous: tuple[float, float] | None = None + for x, y in points: + if previous is not None: + cumulative += math.dist(previous, (x, y)) + vertices.append(RouteVertex(x=float(x), y=float(y), z=0.0, chainage_m=cumulative)) + previous = (x, y) + return PlannedRoute( + vertices=vertices, + epsg=planned.epsg, + name=planned.name, + source=planned.source, + crs_input=crs_input or planned.crs_input, + ) + + def trim_route_to_surface( points: list[tuple[float, float]], sampler: Any, diff --git a/config/config_system.py b/config/config_system.py index e501e962..794fa3a8 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -238,6 +238,14 @@ SHEET_SURFACE_METHODS = [ ] # 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") +# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 +# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서 +# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정). +SHEET_SURFACE_AUTO_METHODS = [ + method.strip() + for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",") + if method.strip() +] # 일반 사용자 WF1 자동 확정 기본값 SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")