From 579e337892421dfa24e5c052ee97e4ff4530bb88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 15:20:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(766-1):=20=EB=8B=A4=EB=93=AC=EA=B8=B0?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84=EC=9D=84=20=EA=B8=B0=EB=B3=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EC=8A=A4=EC=9C=84=EC=B9=98=20SURFACE?= =?UTF-8?q?=5FREFINE=5FFLOW=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=EC=B2=98=EB=A6=AC=20=3D=20=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=EB=B6=84=EB=A5=98=20+=20=EB=8B=A4=EB=93=AC?= =?UTF-8?q?=EA=B8=B0(=EB=B6=84=EB=A5=98=20=EC=9E=88=EC=9D=8C)=20=C2=B7=20?= =?UTF-8?q?=EC=A0=90=EC=A7=84=20TIN=20+=20=EB=8B=A4=EB=93=AC=EA=B8=B0(?= =?UTF-8?q?=EB=8A=98)=20=C2=B7=20=ED=99=95=EC=A0=95=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20ptd=5Frefine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve_auto_source_filters 가 늘 `_refine` 판을 돌려줌 — 바탕 마스크(업체 분류 · 점진 TIN)는 다듬기 입력으로 그대로 저장(엔진 길 그대로) - SURFACE_CONFIRM_DEFAULT_FILTER 기본 = ptd_refine · WF1 자동 확정은 엔진이 고른 첫 필터 그대로 - B04 드롭다운 갈래 넷 그대로(관리자 비교용) - 지킴 시험 고침: test_640_lidar_default · test_b04_ground_filters 의 자동 목록 기대값을 다듬기 판으로 · test_762_1_refine 의 스위치 시험은 스위치가 없어져 「기본 흐름」 시험으로 바꿈 Co-Authored-By: Claude Opus 5.5 --- B04_PreProcess/B04_PreProcess_Engine_Ground.py | 9 ++++----- config/config_system_terrain.py | 7 ++----- resources/tester/test_640_lidar_default.py | 7 +++++-- resources/tester/test_762_1_refine.py | 8 ++------ resources/tester/test_b04_ground_filters.py | 10 +++++----- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Ground.py b/B04_PreProcess/B04_PreProcess_Engine_Ground.py index 76393f353..fe966069a 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Ground.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Ground.py @@ -22,7 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Filter_PMF import filter_pmf from B04_PreProcess.B04_PreProcess_Engine_Filter_PTD import filter_ptd from B04_PreProcess.B04_PreProcess_Engine_Filter_RANSAC import filter_ransac from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask -from config.config_system import SURFACE_AUTO_FALLBACK_FILTER, SURFACE_REFINE_FLOW +from config.config_system import SURFACE_AUTO_FALLBACK_FILTER # 다듬기 판(762) — `{기본 필터}_refine` = 기본 필터 지면점을 SMRF 꼴로 한 번 더 거름 REFINE_SUFFIX = "_refine" @@ -69,16 +69,15 @@ def resolve_auto_source_filters( 업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장 안정적이다. **ptd(점진 TIN)는 분류가 있어도 함께 만든다** — 내장 필터 결과를 눈으로 검증할 수 있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 ptd 하나만 남는다. + 둘 다 다듬기 판(`_refine`)으로 지음 — 업체 분류 + 다듬기 · 점진 TIN + 다듬기(요구 607). 「분류 있음」 = 비율 문턱 + 덮임(일부만 분류된 LAS 거름) — 자동 고름에만(요구 558). """ usable = usable_conditional_filters(structured_data) if usable and not has_classified_coverage(structured_data, processed_dir): usable = [] filters = [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER] - # 762 제안 흐름(스위치 켬) — 같은 기본 필터에 다듬기를 붙인 판을 지음 - if SURFACE_REFINE_FLOW: - filters = [f"{key}{REFINE_SUFFIX}" if key in _REFINABLE else key for key in filters] - return filters + # 다듬기 기본(요구 607) — 같은 기본 필터에 다듬기를 붙인 판을 지음(바탕 마스크도 저장) + return [f"{key}{REFINE_SUFFIX}" if key in _REFINABLE else key for key in filters] def base_filter(filter_key: str) -> str | None: diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 8e0b260ee..2d91375ec 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -94,8 +94,7 @@ SURFACE_MODEL_SOURCE_FILTERS = tuple( SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(",")) # 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값. SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "ptd") -# 762 제안 흐름 스위치 — 켜면 자동 전처리 · 확정 기본 = 기본 필터 + 다듬기(`_refine`) · 기본 끔. -SURFACE_REFINE_FLOW = os.getenv("SURFACE_REFINE_FLOW", "False").lower() == "true" +# 자동 전처리 · 확정 기본은 이 필터의 다듬기 판(`_refine`) — 요구 607. # 다듬기(SMRF 꼴) — 칸 · 물체 기울기(rise/run · PDAL 0.15 는 급경사 능선을 깎아 0.3) · 최대 창 · # 판정 문턱 · 기울기 배수 · 낮은 잡점 기울기 — 나머지는 PDAL 기본. SURFACE_REFINE_CELL_M = float(os.getenv("SURFACE_REFINE_CELL_M", "1.0")) @@ -187,9 +186,7 @@ SHEET_SURFACE_AUTO_METHODS = [ ] # 일반 사용자 WF1 자동 확정 기본값 -SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv( - "SURFACE_CONFIRM_DEFAULT_FILTER", "ptd_refine" if SURFACE_REFINE_FLOW else "ptd" -) +SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "ptd_refine") # 확정 표현 = implicit · 평활 끔 — WF1 자동 짓기도 이 표현을 기본 필터에 지음(요구 558). SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "implicit") SURFACE_CONFIRM_DEFAULT_SMOOTH = ( diff --git a/resources/tester/test_640_lidar_default.py b/resources/tester/test_640_lidar_default.py index fce1413c6..276ca90fb 100644 --- a/resources/tester/test_640_lidar_default.py +++ b/resources/tester/test_640_lidar_default.py @@ -43,12 +43,15 @@ def test_일부만_분류된_LAS_는_자동_기본에서_ptd() -> None: partial = np.ones(len(points), dtype=np.uint8) partial[(points[:, 0] < 30) & (np.arange(len(points)) % 4 == 0)] = 2 # 비율 7 % · 덮임 30 % assert classified_coverage(_structured(points, partial)) < 0.5 - assert resolve_auto_source_filters(_structured(points, partial)) == ["ptd"] + assert resolve_auto_source_filters(_structured(points, partial)) == ["ptd_refine"] spread = np.ones(len(points), dtype=np.uint8) spread[::37] = 2 # 비율 2.7 % · 덮임 100 % assert classified_coverage(_structured(points, spread)) == 1.0 - assert resolve_auto_source_filters(_structured(points, spread)) == ["classification", "ptd"] + assert resolve_auto_source_filters(_structured(points, spread)) == [ + "classification_refine", + "ptd_refine", + ] def test_덮임은_저장본을_다시_씀(tmp_path: Path) -> None: diff --git a/resources/tester/test_762_1_refine.py b/resources/tester/test_762_1_refine.py index 1ddbf5d95..c9a952642 100644 --- a/resources/tester/test_762_1_refine.py +++ b/resources/tester/test_762_1_refine.py @@ -1,11 +1,10 @@ -"""762-1 지면점 다듬기(SMRF 꼴) · 제안 흐름 스위치 · 762-2 품질 지표 지킴 시험. +"""762-1 지면점 다듬기(SMRF 꼴) · 766-1 다듬기 기본 흐름 · 762-2 품질 지표 지킴 시험. 합성 비탈(17°) + 능선(높이 6 m · 폭 약 16 m) + 덤불 혹(3 × 3 m · 1.5 m 뜸) — 필터가 덤불까지 지면으로 고른 판 → 다듬기가 혹은 지우고 능선 · 비탈은 남김. """ import numpy as np -import pytest import B04_PreProcess.B04_PreProcess_Engine_Ground as ground from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask @@ -51,13 +50,10 @@ def test_refine_never_adds_points() -> None: assert refine_ground_mask(xyz, np.zeros(len(xyz), dtype=bool)).sum() == 0 -def test_refine_flow_switch(monkeypatch: pytest.MonkeyPatch) -> None: +def test_refine_flow_is_default() -> None: xyz, _, _ = _scene() bounds = np.array([[0, 120], [0, 120], [0, 50]], dtype=np.float64) data = {"xyz": xyz, "bounds": bounds, "classification": np.full(len(xyz), 2, np.uint8)} - # 기본 끔 = 지금 길 그대로 - assert ground.resolve_auto_source_filters(data) == ["classification", "ptd"] - monkeypatch.setattr(ground, "SURFACE_REFINE_FLOW", True) assert ground.resolve_auto_source_filters(data) == ["classification_refine", "ptd_refine"] data["classification"][:] = 0 assert ground.resolve_auto_source_filters(data) == ["ptd_refine"] diff --git a/resources/tester/test_b04_ground_filters.py b/resources/tester/test_b04_ground_filters.py index 9de36928d..041f9ddb7 100644 --- a/resources/tester/test_b04_ground_filters.py +++ b/resources/tester/test_b04_ground_filters.py @@ -109,13 +109,13 @@ def test_auto_filters_prefer_classification_but_always_build_ptd() -> None: classified = np.ones(len(points), dtype=np.uint8) classified[::4] = 2 - # 분류가 있으면 classification 이 기본이고, ptd 는 검증용으로 같이 만든다. + # 분류가 있으면 classification 이 기본이고, ptd 는 검증용으로 같이 만든다(둘 다 다듬기 판). assert resolve_auto_source_filters(_structured(points, classified)) == [ - "classification", - "ptd", + "classification_refine", + "ptd_refine", ] # 분류가 없으면 ptd 하나뿐 — 빈 마스크로 모델을 만들지 않는다. - assert resolve_auto_source_filters(_structured(points, unclassified)) == ["ptd"] + assert resolve_auto_source_filters(_structured(points, unclassified)) == ["ptd_refine"] assert not has_classified_ground(_structured(points, unclassified)) # classification 이 없는 구조화 데이터(옛 캐시)에서도 죽지 않는다. - assert resolve_auto_source_filters(_structured(points)) == ["ptd"] + assert resolve_auto_source_filters(_structured(points)) == ["ptd_refine"] From a64d0fc57eb32f6fa20414a213592ad1cd04676b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 15:42:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(765-2):=20=EC=A7=80=ED=98=95=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=84=A4=EA=B3=84=20=ED=95=9C=20=EB=B2=8C=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=EB=AA=A8=EB=93=88=20=E2=80=94=20=EB=96=A0?= =?UTF-8?q?=20=EB=91=90=EA=B8=B0=20=C2=B7=20=EA=BA=BC=EB=82=B4=20=EB=81=BC?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0(=EC=83=88=20=EB=85=B8=EC=84=A0=20id=20=C2=B7?= =?UTF-8?q?=20=EC=9E=90=EC=8B=9D=20=EB=8B=A4=EC=8B=9C=20=EC=9E=87=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=BD=94=EB=A6=AC=EB=8F=84=20=EC=9D=B4=EB=A6=84)?= =?UTF-8?q?=20=C2=B7=20=EC=A7=80=EB=AC=B8=20=C2=B7=20=EB=A7=9E=EB=B0=94?= =?UTF-8?q?=EA=BF=88(=EC=8B=A4=ED=8C=A8=20=3D=20=ED=86=B5=EC=A7=B8=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_design_sets: 활성 벌 = 제자리 · 쉬는 벌 = design_sets//(db.json · files/ · initial_snapshot/ · meta.json) - 벌 몫 = B05 노선 정본 · 코리도 · B06~B09 · 배수 노선 몫 · 초기값 · 공유 캐시(비용면 · 예정노선 · 하강장 · 도엽 좌표 · 강우)는 안 옮김 · 안 지움 - 옮김 = 같은 볼륨 이름 바꿈 · 단계 2 이후 행 · projects.status 함께 - 묶음 없음 · 지문 어긋남 = 그 자리에서 자동 설계 체인 한 번(inner = 마커 · 미리 셈은 부르는 쪽) - 시험 test_765_design_sets 6 (왕복 값 같음 · 새 id · 묵은 벌 · 실패 되돌림) Co-Authored-By: Claude Opus 5.5 --- B03_FileInput/B03_FileInput_Service_Chain.py | 11 +- common_util/common_util_design_sets.py | 458 +++++++++++++++++++ common_util/common_util_initial_snapshot.py | 4 + resources/tester/test_765_design_sets.py | 413 +++++++++++++++++ 4 files changed, 882 insertions(+), 4 deletions(-) create mode 100644 common_util/common_util_design_sets.py create mode 100644 resources/tester/test_765_design_sets.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 34a4eceed..29381950a 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -155,10 +155,12 @@ async def _prepare_drainage_pipes_and_reprofile( async def run_auto_design_chain( - project_id: UUID, surface_model_id: int | None = None + project_id: UUID, surface_model_id: int | None = None, *, inner: bool = False ) -> dict[str, Any] | None: """B05 기본 경로 계산·확정 → B06 기본 횡단 설계 확정을 기본값으로 이어 실행한다. + `inner` = 두 벌 짓기 · 지형 칩 안에서 부름(765) — 진입 막음 마커 · 추천 미리 셈은 부르는 쪽. + WF1 자동 확정 직후 같은 백그라운드 태스크에서 호출된다. 어떤 단계가 실패해도 예외를 밖으로 던지지 않는다 — 로그와 각 단계의 workflow 상태 기록으로 남긴다. @@ -359,12 +361,13 @@ async def run_auto_design_chain( mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}") finally: # 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다. - if project_root is not None: + if project_root is not None and not inner: clear_designing(project_root) # 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀 - from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + if not inner: + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute - schedule_precompute(project_id) + schedule_precompute(project_id) async def _ensure_initial_polyline( diff --git a/common_util/common_util_design_sets.py b/common_util/common_util_design_sets.py new file mode 100644 index 000000000..83a3dc8d0 --- /dev/null +++ b/common_util/common_util_design_sets.py @@ -0,0 +1,458 @@ +"""지형마다 설계 한 벌(765 · 요구 605 · 606-1) — 활성 벌은 제자리(DB · 파일) · 쉬는 벌은 묶음. + +묶음 = `<프로젝트>/design_sets//` + db.json 노선과 자식 넷 · 단계 2 이후 행 · projects.status + files/ 벌 몫 파일(프로젝트 뿌리 기준 상대 경로 그대로) + initial_snapshot/ 그 벌의 초기값([초기화]) + meta.json 지형 · 지문(필터 · 표현 · 평활 · 모델 파일 내용) · 옮긴 자리 · 못 지은 까닭 +벌 몫 = B05 노선 정본 · 코리도 · B06~B09 · 배수 결과 중 노선 몫 · 초기값. +공유(안 옮김 · 안 지움) = B05 비용면 · 예정노선 · 추천 고름 · 배수 공유 캐시 + (도엽 좌표 · 하강장 · 강우) · 그 밖 전부. +떠 두기 = 벌 몫을 묶음으로 옮기고(같은 볼륨 = 이름 바꿈) DB 노선 지움 · 단계 2 이후 처음. +꺼내 끼우기 = 새 노선 id 로 넣고 자식 다시 잇기 · 코리도 이름(`rekey_id_files`) · 묶음 지움. +지형 칩 = 맞바꿈(`swap_design_set`) — 실패 = `save_guard` 로 통째 되돌림 · 묶음 없음 · 지문 어긋남 = +그 자리에서 자동 설계 체인으로 한 번 지음. 계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import time +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql + +from common_util.common_util_initial_snapshot import ( + DRAINAGE_SHARED_PREFIXES, + SNAPSHOT_DIRNAME, + _json_safe, +) +from common_util.common_util_surface_confirmation import ( + AnalysisTerrain, + analysis_terrain_params, + analysis_terrain_sources, + design_terrain, + get_surface_confirmation_params, +) + +SETS_DIRNAME = "design_sets" +MODELS_DIR = Path("B04_PreProcess") / "models" +TERRAINS: tuple[AnalysisTerrain, AnalysisTerrain] = ("las", "sheet") +#: 통째로 벌 몫인 폴더 +_SET_DIRS = ( + "B05_Profile/corridor", + "B06_Section", + "B07_DesignDetail", + "B08_Quantity", + "B09_Estimation", + SNAPSHOT_DIRNAME, +) +#: 공유를 뺀 나머지가 벌 몫인 폴더 → 공유 이름 앞머리 +_SPLIT_DIRS = { + "B05_Profile/route": ("cost_surface_", "expected_route", "initial_choice"), + "B04_PreProcess/drainage": DRAINAGE_SHARED_PREFIXES, +} +_ROUTE_CHILDREN = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") +_STAGE_COLUMNS = ( + "state", + "progress_percent", + "params", + "message", + "started_at", + "completed_at", +) +#: 벌이 되는 단계(노선 · 횡단 · 상세 · 수량 · 내역) +FIRST_SET_STAGE = 2 + + +def sets_dir(root: Path) -> Path: + return Path(root) / SETS_DIRNAME + + +def bundle_dir(root: Path, terrain: str) -> Path: + return sets_dir(root) / terrain + + +def set_entries(root: Path) -> list[str]: + """지금 제자리에 있는 벌 몫(프로젝트 뿌리 기준 상대 경로 · 폴더 또는 파일).""" + root = Path(root) + out = [name for name in _SET_DIRS if (root / name).exists()] + for folder, shared in _SPLIT_DIRS.items(): + base = root / folder + if base.is_dir(): + out += [ + f"{folder}/{path.name}" + for path in sorted(base.iterdir()) + if not path.name.startswith(shared) + ] + return out + + +def _bundle_path(bundle: Path, relative: str) -> Path: + if relative == SNAPSHOT_DIRNAME: + return bundle / SNAPSHOT_DIRNAME + return bundle / "files" / relative + + +def _move(source: Path, target: Path) -> None: + if target.is_dir(): + shutil.rmtree(target) + elif target.exists(): + target.unlink() + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(target)) + + +def read_meta(root: Path, terrain: str) -> dict[str, Any]: + try: + return json.loads((bundle_dir(root, terrain) / "meta.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, default=str), encoding="utf-8") + + +def note_unbuilt(root: Path, terrain: str, reason: str) -> None: + """그 지형 벌을 못 지음 — 묶음 비우고 meta.json 에 까닭만.""" + bundle = bundle_dir(root, terrain) + shutil.rmtree(bundle, ignore_errors=True) + _write_json(bundle / "meta.json", {"terrain": terrain, "built": False, "reason": reason}) + + +def discard_sets(root: Path) -> bool: + """묶음 모두 버림(전처리 다시 · 입력 바뀜).""" + target = sets_dir(root) + if not target.exists(): + return False + shutil.rmtree(target, ignore_errors=True) + return True + + +def other_terrain(params: dict[str, Any], root: Path) -> AnalysisTerrain | None: + """설계 지형 말고 고를 수 있는 지형 — 없으면 None.""" + design = design_terrain(params) + sources = analysis_terrain_sources(params, Path(root) / MODELS_DIR) + return next((name for name in sources if name != design), None) + + +# ── 지문 ─────────────────────────────────────────────────────────────────── + +_FINGERPRINT_KEYS = ("terrain", "source_filter", "method", "smooth", "model_digest") + + +async def set_fingerprint( + connection: aiomysql.Connection, project_id: str, root: Path, terrain: str +) -> dict[str, Any] | None: + """그 지형 벌이 기댄 입력 — 지형 · 필터 · 표현 · 평활 · 지표면 모델 id · 모델 파일 내용 지문. + 그 지형 모델이 없으면 None.""" + from B04_PreProcess.B04_PreProcess_Repository import list_surface_models + from common_util.common_util_derived_store import file_digest + + params = await get_surface_confirmation_params(connection, project_id) + target = analysis_terrain_params(params, terrain) # type: ignore[arg-type] + if design_terrain(target) != terrain: + return None + for model in await list_surface_models(connection, UUID(project_id)): + generation = model.get("generation_params") or {} + if ( + model["status"] in ("COMPLETE", "CONFIRMED") + and str(model["model_type"]).lower() == str(target["method"]).lower() + and str(generation.get("source_filter", "")).lower() + == str(target["source_filter"]).lower() + ): + path = model.get("model_file_path") + digest = await asyncio.to_thread(file_digest, Path(root) / path if path else None) + return { + "terrain": terrain, + "source_filter": str(target["source_filter"]), + "method": str(target["method"]), + "smooth": bool(target.get("smooth")), + "model_id": int(model["id"]), + "model_digest": digest, + } + return None + + +def bundle_ready(root: Path, terrain: str, fingerprint: dict[str, Any] | None) -> bool: + """묶음이 있고 지금 입력과 같은 지문인가(모델 id 는 안 봄 — 복제 · 갈음에 바뀜).""" + meta = read_meta(root, terrain) + if not fingerprint or not meta.get("built"): + return False + if not (bundle_dir(root, terrain) / "db.json").is_file(): + return False + return all(meta.get(key) == fingerprint.get(key) for key in _FINGERPRINT_KEYS) + + +# ── DB ───────────────────────────────────────────────────────────────────── + + +async def _select(cursor: Any, sql: str, args: Any) -> list[dict[str, Any]]: + await cursor.execute(sql, args) + return _json_safe([dict(row) for row in await cursor.fetchall()]) + + +async def _dump_db(connection: aiomysql.Connection, project_id: str) -> dict[str, Any]: + async with connection.cursor(aiomysql.DictCursor) as cursor: + dump: dict[str, Any] = { + "routes": await _select( + cursor, "SELECT * FROM routes WHERE project_id=%s ORDER BY id", (project_id,) + ), + "stages": await _select( + cursor, + "SELECT stage_no, " + ", ".join(_STAGE_COLUMNS) + " FROM project_workflow_stages" + " WHERE project_id=%s AND stage_no >= %s ORDER BY stage_no", + (project_id, FIRST_SET_STAGE), + ), + "project_status": ( + await _select(cursor, "SELECT status FROM projects WHERE id=%s", (project_id,)) + or [{}] + )[0].get("status"), + } + ids = [row["id"] for row in dump["routes"]] or [-1] + marks = ",".join(["%s"] * len(ids)) + for table in _ROUTE_CHILDREN: + dump[table] = await _select( + cursor, f"SELECT * FROM {table} WHERE route_id IN ({marks}) ORDER BY id", ids + ) + return dump + + +async def _clear_db(connection: aiomysql.Connection, project_id: str) -> None: + """노선(자식은 CASCADE) 지움 · 단계 2 이후 처음 — 한 트랜잭션.""" + from common_util.common_util_workflow_state import reset_stages_after_input_change + + await connection.begin() + try: + async with connection.cursor() as cursor: + await cursor.execute("DELETE FROM routes WHERE project_id=%s", (project_id,)) + await reset_stages_after_input_change(cursor, project_id, FIRST_SET_STAGE) + await connection.commit() + except Exception: + await connection.rollback() + raise + + +async def _insert(cursor: Any, table: str, row: dict[str, Any]) -> int: + names = ", ".join(f"`{name}`" for name in row) + marks = ", ".join(["%s"] * len(row)) + await cursor.execute(f"INSERT INTO {table} ({names}) VALUES ({marks})", tuple(row.values())) + return int(cursor.lastrowid) + + +async def _load_db( + connection: aiomysql.Connection, project_id: str, dump: dict[str, Any] +) -> dict[int, int]: + """노선 새 id 로 넣고 자식 다시 잇기 · 단계 · 상태 되씀 — 한 트랜잭션 → 옛 id → 새 id.""" + route_map: dict[int, int] = {} + await connection.begin() + try: + async with connection.cursor() as cursor: + for route in dump.get("routes", []): + row = {**route, "project_id": project_id} + old = int(row.pop("id")) + route_map[old] = await _insert(cursor, "routes", row) + for table in _ROUTE_CHILDREN: + for child in dump.get(table, []): + row = dict(child) + row.pop("id", None) + row["route_id"] = route_map[int(row["route_id"])] + if "project_id" in row: + row["project_id"] = project_id + await _insert(cursor, table, row) + for stage in dump.get("stages", []): + sets = ", ".join(f"`{name}`=%s" for name in _STAGE_COLUMNS) + await cursor.execute( + f"UPDATE project_workflow_stages SET {sets}" + " WHERE project_id=%s AND stage_no=%s", + [stage.get(name) for name in _STAGE_COLUMNS] + [project_id, stage["stage_no"]], + ) + if dump.get("project_status") is not None: + await cursor.execute( + "UPDATE projects SET status=%s WHERE id=%s", + (dump["project_status"], project_id), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return route_map + + +# ── 떠 두기 · 꺼내 끼우기 ─────────────────────────────────────────────────── + + +def _clear_files(root: Path) -> None: + for relative in set_entries(root): + path = Path(root) / relative + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + else: + path.unlink(missing_ok=True) + + +async def clear_active_set(connection: aiomysql.Connection, root: Path, project_id: str) -> None: + """제자리 벌 비움(못 지은 벌 · 반쯤 지은 결과) — 공유 캐시는 그대로.""" + await _clear_db(connection, project_id) + await asyncio.to_thread(_clear_files, root) + + +def _park_files(root: Path, work: Path) -> list[str]: + entries = set_entries(root) + for relative in entries: + _move(Path(root) / relative, _bundle_path(work, relative)) + return entries + + +async def park_active_set( + connection: aiomysql.Connection, + root: Path, + project_id: str, + fingerprint: dict[str, Any], +) -> dict[str, Any]: + """제자리 벌을 `design_sets/<지형>/` 으로 떠 두고 제자리를 비움 → meta.""" + root = Path(root) + terrain = str(fingerprint["terrain"]) + dump = await _dump_db(connection, project_id) + work = sets_dir(root) / f".{terrain}_new" + await asyncio.to_thread(shutil.rmtree, work, True) + _write_json(work / "db.json", dump) + entries = await asyncio.to_thread(_park_files, root, work) + meta = { + **fingerprint, + "built": True, + "entries": entries, + "routes": [row["id"] for row in dump["routes"]], + "parked_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + _write_json(work / "meta.json", meta) + await _clear_db(connection, project_id) + bundle = bundle_dir(root, terrain) + await asyncio.to_thread(shutil.rmtree, bundle, True) + work.rename(bundle) + return meta + + +def _unpark_files(root: Path, bundle: Path, entries: list[str], route_map: dict) -> None: + from common_util.common_util_project_copy import rekey_id_files + + for relative in entries: + source = _bundle_path(bundle, relative) + if source.exists(): + _move(source, Path(root) / relative) + rekey_id_files(Path(root), {}, route_map) + shutil.rmtree(bundle, ignore_errors=True) + + +async def load_set( + connection: aiomysql.Connection, root: Path, project_id: str, terrain: str +) -> dict[int, int] | None: + """묶음을 제자리로 꺼내 끼움(제자리는 비어 있어야 함) → 옛 노선 id → 새 id · + 묶음 없음 = None.""" + bundle = bundle_dir(root, terrain) + try: + dump = json.loads((bundle / "db.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + meta = read_meta(root, terrain) + route_map = await _load_db(connection, project_id, dump) + await asyncio.to_thread(_unpark_files, root, bundle, list(meta.get("entries") or []), route_map) + return route_map + + +# ── 지형 바꿈 ────────────────────────────────────────────────────────────── + + +async def switch_terrain(connection: aiomysql.Connection, project_id: UUID, terrain: str) -> int: + """설계 지형(stage 1 확정값 · 확정 모델)을 바꿈 — 한 트랜잭션 → 확정 모델 id.""" + from B04_PreProcess.B04_PreProcess_Service import switch_design_terrain + + await connection.begin() + try: + switched = await switch_design_terrain(connection, project_id, terrain) # type: ignore[arg-type] + await connection.commit() + except Exception: + await connection.rollback() + raise + if switched: + return int(switched[0]) + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT id FROM surface_models WHERE project_id=%s AND status='CONFIRMED'", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row: + raise LookupError("확정 지표면 모델이 없습니다.") + return int(row[0]) + + +async def swap_design_set(project_id: UUID, root: Path, target: str) -> dict[str, Any]: + """지형 칩 = 맞바꿈 — 지금 벌 떠 둠 → 지형 바꿈 → 고른 벌 꺼내 끼움(없음 · 묵음 = 한 번 지음). + 실패 = DB 행 · 벌 몫 · 묶음을 떠 둔 대로 되돌리고 예외 그대로. 잠금은 부르는 쪽.""" + from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain + from common_util import common_util_save_guard as guard + from common_util.common_util_initial_snapshot import ( + clear_design_failed, + clear_designing, + is_design_failed, + read_design_failure, + ) + from config.config_db import get_db_pool + + started = time.perf_counter() + pid, root = str(project_id), Path(root) + pool = get_db_pool() + async with pool.acquire() as connection: + now = design_terrain(await get_surface_confirmation_params(connection, pid)) + if now == target: + return {"changed": False, "design": now} + here = await set_fingerprint(connection, pid, root, now) or {"terrain": now} + there = await set_fingerprint(connection, pid, root, target) + if there is None: + raise LookupError("고른 지형의 지표면 모델이 없습니다.") + rows = await guard.snapshot_rows(connection, pid, surface=True) + ready = bundle_ready(root, target, there) + # 되돌림 자리 = 지금 벌 몫 + 꺼낼 벌 몫(끼운 뒤 생기는 파일도 지우게) + 묶음 + scope = tuple( + dict.fromkeys( + [*set_entries(root), *(read_meta(root, target).get("entries") or []), SETS_DIRNAME] + ) + ) + files = await asyncio.to_thread(guard.snapshot_storage, root, scope) + had_failed = is_design_failed(root) + had_bundle = bundle_dir(root, now).exists() + try: + async with pool.acquire() as connection: + await park_active_set(connection, root, pid, here) + model_id = await switch_terrain(connection, project_id, target) + loaded = await load_set(connection, root, pid, target) if ready else None + if loaded is None: + await asyncio.to_thread(shutil.rmtree, bundle_dir(root, target), True) + summary = await run_auto_design_chain(project_id, model_id, inner=True) + if summary is None: + raise RuntimeError(read_design_failure(root) or "고른 지형 설계를 짓지 못했습니다.") + except BaseException: + files.restore() + if not had_bundle: # 되돌림은 파일만 — 떠 둔 빈 폴더도 걷음 + shutil.rmtree(bundle_dir(root, now), ignore_errors=True) + async with pool.acquire() as connection: + await guard.restore_rows(connection, pid, rows) + if not had_failed: + clear_design_failed(root) + raise + finally: + files.discard() + clear_designing(root) # 지음 길(inner 체인)이 건 마커 + return { + "changed": True, + "design": target, + "built": loaded is None, + "seconds": round(time.perf_counter() - started, 2), + } diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index b6ee4fe88..07b8dfc9c 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -38,6 +38,10 @@ DESIGN_FAILED_NAME = "initial_design.failed" # 확정: 「초기값 = 파일 입력 직후 결과 전부」). 관을 옮기면 세부유역(`04_detailed_basins`)이 # 다시 나뉘는데 그 산출물이 스냅샷 밖이라 [초기화]가 옛 유역도를 그대로 남겼다. # 용량은 실측 8.6MB(스냅샷 전체 3.9MB → 약 12MB)로 감당할 만하다. +#: 배수 폴더 중 노선과 무관한 공유 캐시(이름 앞머리 · 도엽 좌표 · 하강장 · 강우) — 초기값 사본 · +#: 설계 벌에 안 넣고 복원 · 맞바꿈이 안 지움(765-6) +DRAINAGE_SHARED_PREFIXES = ("00_sheet_metric", "descent_", "rainfall_") + _FILE_TREES = ( "B05_Profile/route", "B06_Section/longitudinal", diff --git a/resources/tester/test_765_design_sets.py b/resources/tester/test_765_design_sets.py new file mode 100644 index 000000000..0cca50bba --- /dev/null +++ b/resources/tester/test_765_design_sets.py @@ -0,0 +1,413 @@ +# -*- coding: utf-8 -*- +"""765-2 지형마다 설계 한 벌 — 떠 두기 · 꺼내 끼우기 · 맞바꿈(`common_util_design_sets`). + +DB = 메모리 sqlite 를 aiomysql 꼴로 감싼 가짜(노선 · 자식 넷 · 단계 · 프로젝트 · 지표면 모델) · +storage = 시험 안 임시 폴더. 지킴: + ① 떠 둔 뒤 꺼내면 값 같음 — 노선 · 자식 · 단계 · 상태 · 벌 몫 파일 · 초기값 · 코리도 + ② 꺼낼 때 새 노선 id · 자식 다시 잇기 · 코리도 이름 새 id + ③ 공유 캐시(비용면 · 예정노선 · 배수 하강장 · 도엽 좌표 · 강우)는 안 옮기고 안 지움 + ④ 지문 어긋난(묵은) 벌은 버리고 그 자리에서 지음 · 실패면 통째 되돌림 +""" + +from __future__ import annotations + +import asyncio +import json +import re +import sqlite3 +import sys +from pathlib import Path +from uuid import uuid4 + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import aiomysql # noqa: E402 + +from common_util import common_util_design_sets as sets # noqa: E402 + +PID = str(uuid4()) + +_SCHEMA = """ +CREATE TABLE projects (id TEXT PRIMARY KEY, status TEXT, name TEXT); +CREATE TABLE surface_models (id INTEGER PRIMARY KEY, project_id TEXT, status TEXT); +CREATE TABLE project_workflow_stages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, stage_no INT, stage_key TEXT, + state TEXT, progress_percent INT, params TEXT, message TEXT, started_at TEXT, completed_at TEXT); +CREATE TABLE routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, surface_model_id INT, status TEXT, + total_length_m REAL, route_data_path TEXT, created_at TEXT); +CREATE TABLE route_points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + route_id INT REFERENCES routes(id) ON DELETE CASCADE, chainage_m REAL, sequence_num INT); +CREATE TABLE route_statistics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + route_id INT REFERENCES routes(id) ON DELETE CASCADE, cut_volume_m3 REAL); +CREATE TABLE longitudinal_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, + route_id INT REFERENCES routes(id) ON DELETE CASCADE, data TEXT); +CREATE TABLE cross_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT, + route_id INT REFERENCES routes(id) ON DELETE CASCADE, chainage_m REAL, data TEXT); +""" + + +class _Cursor: + def __init__(self, db: sqlite3.Connection, dict_rows: bool) -> None: + self.db, self.dict_rows, self.rows, self.lastrowid, self.rowcount = db, dict_rows, [], 0, 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + @staticmethod + def _sql(sql: str) -> str: + return re.sub(r"%s", "?", sql) + + async def execute(self, sql: str, args=()) -> None: + cur = self.db.execute(self._sql(sql), tuple(args or ())) + self.lastrowid, self.rowcount = cur.lastrowid, cur.rowcount + names = [d[0] for d in cur.description or []] + rows = cur.fetchall() + self.rows = [dict(zip(names, r)) for r in rows] if self.dict_rows else rows + + async def executemany(self, sql: str, seq) -> None: + self.db.executemany(self._sql(sql), [tuple(a) for a in seq]) + + async def fetchall(self): + return self.rows + + async def fetchone(self): + return self.rows[0] if self.rows else None + + +class _Conn: + def __init__(self, db: sqlite3.Connection) -> None: + self.db = db + + def cursor(self, kind=None): + return _Cursor(self.db, kind is aiomysql.DictCursor) + + async def begin(self): + pass + + async def commit(self): + self.db.commit() + + async def rollback(self): + self.db.rollback() + + +class _Pool: + def __init__(self, conn: _Conn) -> None: + self.conn = conn + + def acquire(self): + conn = self.conn + + class _Ctx: + async def __aenter__(self): + return conn + + async def __aexit__(self, *_): + return False + + return _Ctx() + + +def _seed_db() -> sqlite3.Connection: + db = sqlite3.connect(":memory:") + db.execute("PRAGMA foreign_keys = ON") + db.executescript(_SCHEMA) + db.execute("INSERT INTO projects VALUES (?, 'WF3_ANALYZING', 'p')", (PID,)) + db.executemany( + "INSERT INTO surface_models VALUES (?, ?, ?)", + [(5, PID, "CONFIRMED"), (6, PID, "COMPLETE")], + ) + for no in range(7): + db.execute( + "INSERT INTO project_workflow_stages (project_id, stage_no, stage_key, state," + " progress_percent, params, message) VALUES (?, ?, ?, ?, ?, ?, ?)", + (PID, no, f"S{no}", "IN_PROGRESS", 50, json.dumps({"no": no, "k": "las"}), f"m{no}"), + ) + for rid in (10, 11): + db.execute( + "INSERT INTO routes (id, project_id, surface_model_id, status, total_length_m," + " route_data_path, created_at) VALUES (?, ?, 5, 'CONFIRMED', ?, 'r.geojson', 't')", + (rid, PID, rid * 100.0), + ) + for seq in range(3): + db.execute( + "INSERT INTO route_points (route_id, chainage_m, sequence_num) VALUES (?, ?, ?)", + (rid, seq * 20.0, seq), + ) + db.execute( + "INSERT INTO cross_sections (project_id, route_id, chainage_m, data)" + " VALUES (?, ?, ?, ?)", + (PID, rid, seq * 20.0, json.dumps({"cut": seq})), + ) + db.execute("INSERT INTO route_statistics (route_id, cut_volume_m3) VALUES (?, 7)", (rid,)) + db.execute( + "INSERT INTO longitudinal_sections (project_id, route_id, data) VALUES (?, ?, '{}')", + (PID, rid), + ) + db.commit() + return db + + +_SET_FILES = { + "B05_Profile/route/planned_route.csv": "x,y\n1,2\n", + "B05_Profile/route/structures.json": '{"items": [1]}', + "B05_Profile/route/route_main.geojson": "{}", + "B05_Profile/corridor/corridor_0011.json": '{"route": 11}', + "B06_Section/longitudinal/longitudinal.json": '{"z": [1, 2]}', + "B06_Section/sampling.json": '{"surface_model_id": 5}', + "B04_PreProcess/drainage/01_primary_region.geojson": "{}", + "B04_PreProcess/drainage/edits/pipe_points.json": "[1]", + "B04_PreProcess/drainage/manifest.json": "{}", + "initial_snapshot/db.json": '{"routes": []}', +} +_SHARED_FILES = { + "B05_Profile/route/cost_surface_x_implicit.npz": "c", + "B05_Profile/route/expected_route.csv": "e", + "B05_Profile/route/initial_choice.json": "{}", + "B04_PreProcess/drainage/descent_ab_cd.npz": "d", + "B04_PreProcess/drainage/00_sheet_metric.pkl": "s", + "B04_PreProcess/drainage/rainfall_table.json": "r", + "B04_PreProcess/models/dtm_x.npz": "m", +} + + +def _seed_files(root: Path) -> None: + for relative, text in {**_SET_FILES, **_SHARED_FILES}.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _tree(root: Path) -> dict[str, str]: + return { + p.relative_to(root).as_posix(): p.read_text(encoding="utf-8") + for p in sorted(root.rglob("*")) + if p.is_file() and SETS_DIR not in p.relative_to(root).parts + } + + +SETS_DIR = sets.SETS_DIRNAME +FP = { + "terrain": "las", + "source_filter": "classification", + "method": "implicit", + "smooth": False, + "model_id": 5, + "model_digest": "aa", +} + + +def _rows(db: sqlite3.Connection, sql: str, args=()) -> list[tuple]: + return db.execute(sql, args).fetchall() + + +def _no_ids(db: sqlite3.Connection) -> dict[str, list]: + """id 를 뺀 값(자식은 노선 차례로) — 왕복 비교용.""" + order = {rid: i for i, (rid,) in enumerate(_rows(db, "SELECT id FROM routes ORDER BY id"))} + out = { + "routes": _rows( + db, + "SELECT project_id, surface_model_id, status, total_length_m, route_data_path," + " created_at FROM routes ORDER BY id", + ), + "stages": _rows( + db, + "SELECT stage_no, state, progress_percent, params, message FROM" + " project_workflow_stages ORDER BY stage_no", + ), + "status": _rows(db, "SELECT status FROM projects"), + } + for table, cols in ( + ("route_points", "chainage_m, sequence_num"), + ("route_statistics", "cut_volume_m3"), + ("longitudinal_sections", "project_id, data"), + ("cross_sections", "project_id, chainage_m, data"), + ): + out[table] = sorted( + (order[r[0]], *r[1:]) + for r in _rows(db, f"SELECT route_id, {cols} FROM {table} ORDER BY id") + ) + return out + + +def test_벌_몫과_공유를_가름(tmp_path: Path) -> None: + _seed_files(tmp_path) + entries = sets.set_entries(tmp_path) + assert "B06_Section" in entries and "initial_snapshot" in entries + assert "B05_Profile/route/structures.json" in entries + assert "B04_PreProcess/drainage/edits" in entries + joined = " ".join(entries) + for shared in ("cost_surface_", "expected_route", "initial_choice", "descent_", "00_sheet"): + assert shared not in joined + assert "rainfall_" not in joined and "models" not in joined + + +def test_떠_두고_꺼내면_값_같음_새_id(tmp_path: Path) -> None: + db = _seed_db() + conn = _Conn(db) + _seed_files(tmp_path) + before_db, before_files = _no_ids(db), _tree(tmp_path) + + meta = asyncio.run(sets.park_active_set(conn, tmp_path, PID, FP)) + assert meta["routes"] == [10, 11] and meta["built"] + assert _rows(db, "SELECT COUNT(*) FROM routes") == [(0,)] + assert _rows(db, "SELECT COUNT(*) FROM cross_sections") == [(0,)] + states = _rows(db, "SELECT stage_no, state, params FROM project_workflow_stages ORDER BY 1") + assert states[:2] == [(0, "IN_PROGRESS", before_db["stages"][0][3])] + [ + (1, "IN_PROGRESS", before_db["stages"][1][3]) + ] + assert all(s[1] == "NOT_STARTED" and s[2] is None for s in states[2:]) + left = _tree(tmp_path) + assert set(left) == set(_SHARED_FILES) # 공유만 남음 · 그대로 + assert all(left[k] == v for k, v in _SHARED_FILES.items()) + bundle = tmp_path / SETS_DIR / "las" + assert (bundle / "db.json").is_file() and (bundle / "initial_snapshot" / "db.json").is_file() + assert (bundle / "files" / "B06_Section" / "sampling.json").is_file() + assert sets.bundle_ready(tmp_path, "las", FP) + + route_map = asyncio.run(sets.load_set(conn, tmp_path, PID, "las")) + assert set(route_map) == {10, 11} + assert not set(route_map.values()) & {10, 11} # 새 id + assert _no_ids(db) == before_db # 노선 · 자식 · 단계 · 상태 같음 + after = _tree(tmp_path) + moved = before_files.pop("B05_Profile/corridor/corridor_0011.json") + new_corridor = f"B05_Profile/corridor/corridor_{route_map[11]:04d}.json" + assert after.pop(new_corridor) == moved # 코리도 = 새 id 이름 + assert after == before_files + assert not bundle.exists() # 꺼낸 묶음은 지움 + + +def test_지문_어긋나면_묵은_벌(tmp_path: Path) -> None: + db = _seed_db() + _seed_files(tmp_path) + asyncio.run(sets.park_active_set(_Conn(db), tmp_path, PID, FP)) + assert sets.bundle_ready(tmp_path, "las", FP) + assert sets.bundle_ready(tmp_path, "las", {**FP, "model_id": 99}) # 복제 · 갈음 id 는 안 봄 + assert not sets.bundle_ready(tmp_path, "las", {**FP, "model_digest": "bb"}) + assert not sets.bundle_ready(tmp_path, "las", {**FP, "source_filter": "csf"}) + assert not sets.bundle_ready(tmp_path, "las", None) + assert not sets.bundle_ready(tmp_path, "sheet", {**FP, "terrain": "sheet"}) + sets.note_unbuilt(tmp_path, "sheet", "수치지형도 모델 없음") + assert sets.read_meta(tmp_path, "sheet") == { + "terrain": "sheet", + "built": False, + "reason": "수치지형도 모델 없음", + } + assert not sets.bundle_ready(tmp_path, "sheet", {**FP, "terrain": "sheet"}) + + +def _swap_world(monkeypatch, tmp_path: Path, *, sheet_fp: dict, chain): + """las 활성 · sheet 묶음(지문 = sheet_fp) — 칩으로 sheet.""" + import B03_FileInput.B03_FileInput_Service_Chain as chain_mod + import config.config_db as config_db + + db = _seed_db() + conn = _Conn(db) + _seed_files(tmp_path) + sheet = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_id": 6} + # sheet 벌을 미리 떠 둠: 노선 하나(길이 777) + asyncio.run(sets.park_active_set(conn, tmp_path, PID, FP)) + db.execute( + "INSERT INTO routes (project_id, surface_model_id, status, total_length_m) VALUES" + " (?, 6, 'CONFIRMED', 777)", + (PID,), + ) + db.commit() + (tmp_path / "B06_Section").mkdir(parents=True, exist_ok=True) + (tmp_path / "B06_Section" / "sheet.json").write_text("sheet", encoding="utf-8") + asyncio.run(sets.park_active_set(conn, tmp_path, PID, sheet_fp)) + asyncio.run(sets.load_set(conn, tmp_path, PID, "las")) + state = {"design": "las"} + + async def params(_c, _pid): + return { + "source_filter": "sheet_laplace" if state["design"] == "sheet" else "classification" + } + + async def fingerprint(_c, _pid, _root, terrain): + return sheet if terrain == "sheet" else FP + + async def switch(_c, _pid, terrain): + state["design"] = terrain + db.execute( + "UPDATE surface_models SET status = CASE id WHEN 6 THEN 'CONFIRMED' ELSE 'COMPLETE' END" + ) + return 6 + + monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool(conn)) + monkeypatch.setattr(sets, "get_surface_confirmation_params", params) + monkeypatch.setattr(sets, "set_fingerprint", fingerprint) + monkeypatch.setattr(sets, "switch_terrain", switch) + monkeypatch.setattr(chain_mod, "run_auto_design_chain", chain) + return db, state + + +def test_맞바꿈_묶음_있으면_꺼냄(monkeypatch, tmp_path: Path) -> None: + called = [] + + async def chain(*a, **k): + called.append(a) + + sheet_fp = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_id": 6} + db, _ = _swap_world(monkeypatch, tmp_path, sheet_fp=sheet_fp, chain=chain) + result = asyncio.run(sets.swap_design_set(PID, tmp_path, "sheet")) # type: ignore[arg-type] + assert result["changed"] and not result["built"] and not called + assert _rows(db, "SELECT total_length_m FROM routes") == [(777.0,)] + assert (tmp_path / "B06_Section" / "sheet.json").read_text(encoding="utf-8") == "sheet" + assert sets.bundle_ready(tmp_path, "las", FP) # 떠난 벌 살림 + assert not (tmp_path / SETS_DIR / "sheet").exists() + + +def test_맞바꿈_묵은_벌은_버리고_지음(monkeypatch, tmp_path: Path) -> None: + called = [] + holder: dict = {} + + async def chain(pid, model_id, inner=False): + called.append((model_id, inner)) + holder["db"].execute( + "INSERT INTO routes (project_id, surface_model_id, status, total_length_m) VALUES" + " (?, 6, 'CONFIRMED', 555)", + (PID,), + ) + return {"route_id": 1} + + stale = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_digest": "old"} + db, _ = _swap_world(monkeypatch, tmp_path, sheet_fp=stale, chain=chain) + holder["db"] = db + result = asyncio.run(sets.swap_design_set(PID, tmp_path, "sheet")) # type: ignore[arg-type] + assert result["built"] and called == [(6, True)] + assert _rows(db, "SELECT total_length_m FROM routes") == [(555.0,)] # 묵은 777 버림 + assert not (tmp_path / "B06_Section" / "sheet.json").exists() + assert sets.bundle_ready(tmp_path, "las", FP) + + +def test_맞바꿈_실패면_통째_되돌림(monkeypatch, tmp_path: Path) -> None: + async def chain(*_a, **_k): + return None # 지음 실패 + + stale = {**FP, "terrain": "sheet", "source_filter": "sheet_laplace", "model_digest": "old"} + db, _ = _swap_world(monkeypatch, tmp_path, sheet_fp=stale, chain=chain) + before_db, before_files = _no_ids(db), _tree(tmp_path) + route_ids = _rows(db, "SELECT id FROM routes ORDER BY id") + sheet_meta = sets.read_meta(tmp_path, "sheet") + models = _rows(db, "SELECT id, status FROM surface_models ORDER BY id") + with pytest.raises(RuntimeError): + asyncio.run(sets.swap_design_set(PID, tmp_path, "sheet")) # type: ignore[arg-type] + assert _no_ids(db) == before_db + assert _rows(db, "SELECT id FROM routes ORDER BY id") == route_ids + assert _rows(db, "SELECT id, status FROM surface_models ORDER BY id") == models + assert _tree(tmp_path) == before_files + assert sets.read_meta(tmp_path, "sheet") == sheet_meta # 묶음도 그대로 + assert not (tmp_path / SETS_DIR / "las").exists() From a44fd98adf72dd3e0f479aa1a614523e6778052f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 15:47:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(765-3):=20=EC=A0=84=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=B2=B4=EC=9D=B8=20=3D=20=EC=A7=80=ED=98=95=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EB=91=90=20=EB=B2=8C=20=E2=80=94=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=A7=80=ED=98=95=20=EB=A8=BC=EC=A0=80=20=EC=A7=93?= =?UTF-8?q?=EA=B3=A0=20=EB=AC=B6=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=EB=96=A0=20?= =?UTF-8?q?=EB=91=A0=20=E2=86=92=20=EC=84=A4=EA=B3=84=20=EC=A7=80=ED=98=95?= =?UTF-8?q?=20=E2=86=92=20=EC=B6=94=EC=B2=9C=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EC=85=88=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=20=EB=8A=98=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=EC=9A=B0=EA=B8=B0=EC=97=90=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A3=BD=EC=9D=80=20run=5Fredesign=5Fchain=20?= =?UTF-8?q?=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_preprocess_chain: WF1 자동 확정 · 서피스 바꿈 체인이 부름 · 다른 지형 없음 · 실패 = 한 벌 + meta.json 까닭 · 예상노선(공유)은 설계 지형 벌이 씀 - 자동 체인 배수 refresh=True — 저장분이 노선 바뀐 줄 모르던 틈 막음 - purge_output_folders 가 design_sets 도 지움 - run_redesign_chain(앱에서 부르는 곳 0) 걷음 — 시험 정리: test_342_2 의 그 함수 시험 셋 지움(함수 없음) · test_763_3 · test_351_1 은 두 벌 체인으로 · test_74 · test_user_values_survive 의 이월 차례 검사는 살아 있는 자리(B05_Profile_Route_Save)로 옮김 - 시험 test_765_design_sets 에 체인 차례 · 한 벌 · 다른 벌 실패 · 비우기 넷 덧 Co-Authored-By: Claude Opus 5.5 --- B03_FileInput/B03_FileInput_Service_Chain.py | 335 +++++------------- .../B03_FileInput_Service_SurfaceChange.py | 11 +- B03_FileInput/B03_FileInput_Service_WF1.py | 7 +- common_util/common_util_project_reset.py | 10 +- resources/tester/test_342_2_chain_once.py | 228 +----------- resources/tester/test_351_1_recommend_gaps.py | 10 +- .../tester/test_74_route_confirm_initial.py | 5 +- resources/tester/test_763_3_surface_change.py | 11 +- resources/tester/test_765_design_sets.py | 106 ++++++ resources/tester/test_user_values_survive.py | 11 +- 10 files changed, 233 insertions(+), 501 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 29381950a..e51abe468 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -13,12 +13,13 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어 사용자는 그 페이지에서 수동으로 이어서 진행할 수 있다. - 라우터 함수를 직접 호출한다(HTTP 재진입 없음). solve/confirm 엔드포인트는 인증 의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다. +- **지형마다 한 벌**(765-3): 전처리 체인 = `run_preprocess_chain` — 다른 지형 벌을 먼저 지어 + `design_sets/` 묶음으로 떠 두고 설계 지형 벌을 지음(`common_util_design_sets`). """ import asyncio import logging import time -from dataclasses import asdict from pathlib import Path from typing import Any from uuid import UUID @@ -311,7 +312,8 @@ async def run_auto_design_chain( # 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출. # B06 확정 전에 끝내야 확정 스냅샷에 배관 정착 계획선이 담긴다. - await _prepare_drainage_pipes_and_reprofile(project_id, route_id) + # 배수는 늘 다시 셈(765-3) — 저장분은 노선이 바뀐 줄 모름(두 벌 · 서피스 바꿈 뒤 묵음). + await _prepare_drainage_pipes_and_reprofile(project_id, route_id, refresh=True) # 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장. stage 3은 # IN_PROGRESS(스텝바 노란 표시)로 남겨 사용자 검토·확정을 기다린다. @@ -370,11 +372,94 @@ async def run_auto_design_chain( schedule_precompute(project_id) +async def run_preprocess_chain( + project_id: UUID, surface_model_id: int | None = None +) -> dict[str, Any] | None: + """전처리 체인 = 지형마다 설계 한 벌(765-3 · 요구 605 · 606-1) — 결과 = 설계 지형 요약. + + 다른 지형 먼저 자동 설계 → 묶음으로 떠 둠(B05~B09 비움) → 설계 지형 자동 설계 → 추천 미리 셈 + 한 번(실패 길에도). 다른 지형이 없으면(LAS 만 · 수치지형도 못 받음 · 수치지형도만) 한 벌 · + 묶음 meta.json 에 까닭. 부르는 곳 = WF1 자동 확정 · 서피스 바꿈 체인(B04 다른 서피스 [확정] · + LAS 나중 올리기 뒤 전처리 다시). 묶음은 처음부터 다시(옛 벌 버림). + """ + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute + from common_util import common_util_design_sets as sets + from common_util.common_util_initial_snapshot import ( + clear_designing, + mark_designing, + read_design_failure, + ) + from common_util.common_util_route_geometry import expected_route_csv_path + from common_util.common_util_storage import resolve_stored_project_path + from common_util.common_util_surface_confirmation import ( + design_terrain, + get_surface_confirmation_params, + ) + from config.config_db import get_db_pool + + pool = get_db_pool() + root: Path | None = None + marks = [("시작", time.perf_counter())] + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + params = await get_surface_confirmation_params(connection, str(project_id)) + if not stored_path: + return None + root = Path(resolve_stored_project_path(stored_path)) + mark_designing(root) + sets.discard_sets(root) + design = design_terrain(params) + other = sets.other_terrain(params, root) + if other is None: + missing = "sheet" if design == "las" else "las" + sets.note_unbuilt(root, missing, f"{missing} 지표면 모델 없음 — 한 벌만") + else: + # 예상노선(공유)은 설계 지형 벌이 씀 — 다른 지형으로 자른 것이 남지 않게 + expected = expected_route_csv_path(root) + had_expected = expected.is_file() + try: + async with pool.acquire() as connection: + model_id = await sets.switch_terrain(connection, project_id, other) + built = await run_auto_design_chain(project_id, model_id, inner=True) + async with pool.acquire() as connection: + fingerprint = await sets.set_fingerprint( + connection, str(project_id), root, other + ) + if built and fingerprint: + await sets.park_active_set(connection, root, str(project_id), fingerprint) + else: + await sets.clear_active_set(connection, root, str(project_id)) + sets.note_unbuilt(root, other, read_design_failure(root) or "짓기 실패") + except Exception as exc: # noqa: BLE001 — 다른 벌 실패가 설계 지형 벌을 막지 않음 + logger.exception("두 벌 짓기 — %s 벌 실패: project_id=%s", other, project_id) + async with pool.acquire() as connection: + await sets.clear_active_set(connection, root, str(project_id)) + sets.note_unbuilt(root, other, f"짓기 오류: {exc}") + finally: + if not had_expected: + expected.unlink(missing_ok=True) + async with pool.acquire() as connection: + await sets.switch_terrain(connection, project_id, design) + marks.append((f"{other} 벌", time.perf_counter())) + summary = await run_auto_design_chain(project_id, surface_model_id, inner=True) + marks.append((f"{design} 벌", time.perf_counter())) + _log_steps("전처리 체인 두 벌", marks) + return summary + except Exception: + logger.exception("전처리 체인 실패: project_id=%s", project_id) + return None + finally: + if root is not None: + clear_designing(root) + schedule_precompute(project_id) # 추천은 지형 · 예정노선만 씀 — 실패 길에도(763-3) + + async def _ensure_initial_polyline( project_id: UUID, project_root: Path, points: list[dict[str, float]] ) -> None: """계획노선 초기 폴리라인을 세운다 — 이미 있으면 그대로 둔다. 실패는 비치명적.""" - import asyncio from B05_Profile.B05_Profile_Router_FollowLand import initial_land from B05_Profile.B05_Profile_Router_Replan import _ensure_planned_initial, _min_plan_radius_m @@ -445,247 +530,3 @@ def redesign_solve_request( start_elevation_offset_m=params.get("start_elevation_offset_m"), end_elevation_offset_m=params.get("end_elevation_offset_m"), ) - - -async def run_redesign_chain( - project_id: UUID, - surface_model_id: int, - selection: dict[str, Any], - profile_target: tuple[list[float], list[float]] | None = None, -) -> str | None: - """B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장. - - 관리자가 B04에서 다른 지표면 모델로 재확정하면(2026-08-04 사용자 확정) 그 값을 - 기준으로 다음 페이지들도 함께 갱신돼야 한다. 이때 일반 사용자가 이미 쓰던 설정은 - 버리지 않는다: - - B05: 저장된 stage 2 params(제어점 BP/EP/CP·회피/금지원·경사 옵션·측점 간격 등)를 - 그대로 쓰고 **지표면(filter/method/smooth/model id)만** 새 확정값으로 바꾼다. - - B06: 옛 경로의 측점별 설계(지반유형·단면유형·측구·암 경계)를 chainage 매칭으로 - 새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정 - 측점은 확정 시 기본값으로 채워진다. - - 경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다. - - `profile_target`(누가거리 · 계획고)이 오면 1차 종단을 그 계획고로 세운다 — 노선 추천 - 「종단 반영」 켬으로 [적용]한 노선의 균형 종단(PLAN 74-4). 없으면 계획고 = 지반고. - """ - from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path - from B05_Profile.B05_Profile_Repository import get_latest_route - from B05_Profile.B05_Profile_Router import ( - _section_options, - confirm_latest_route, - solve_route_record, - ) - from B06_Section.B06_Section_Repository import ( - get_cross_section_designs, - get_latest_section_options, - get_longitudinal_section, - ) - from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs - from B06_Section.B06_Section_Router import regenerate_route_sections - from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections - from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs - from B06_Section.B06_Section_Schema import SectionConfirmRequest - from common_util.common_util_initial_snapshot import ( - clear_designing, - discard_initial_snapshot, - mark_designing, - ) - from common_util.common_util_storage import resolve_stored_project_path - from common_util.common_util_workflow_state import get_workflow_state - from config.config_db import get_db_pool - - pool = get_db_pool() - project_root: Path | None = None - marks = [("시작", time.perf_counter())] - try: - async with pool.acquire() as connection: - latest = await get_latest_route(connection, project_id) - stored_path = await get_project_storage_relative_path(connection, project_id) - if stored_path: - project_root = Path(resolve_stored_project_path(stored_path)) - mark_designing(project_root) - # 지표면이 바뀌면 옛 초기값은 다른 지형 기준이라 더는 기준이 아니다. 여기서 - # 다시 뜨지는 않는다 — 이 체인은 **사용자 입력을 유지한 채** 재계산하므로 그 - # 결과를 찍으면 편집이 섞인 가짜 초기값이 된다(2026-08-29 사용자 확정). - if discard_initial_snapshot(project_root): - logger.info("재확정 체인: 초기값 스냅샷 무효화 (project_id=%s)", project_id) - if not latest: - logger.info( - "재확정 체인 → 기존 경로 없음, 신규 자동 체인으로: project_id=%s", project_id - ) - await run_auto_design_chain(project_id, surface_model_id=surface_model_id) - return - - # 1) 사용자 입력 회수 — 마지막 경로 계산의 stage 2 params가 정본이다. - old_route_id = int(latest["id"]) - async with pool.acquire() as connection: - import aiomysql - - async with connection.cursor(aiomysql.DictCursor) as cursor: - state = await get_workflow_state(cursor, str(project_id)) - old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id) - old_designs = await get_cross_section_designs(connection, old_route_id) - old_section_options = await get_latest_section_options(connection, project_id) - stage2 = next( - (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None - ) - params = (stage2 or {}).get("params") or {} - points = params.get("points") or {} - if not points.get("bp") or not points.get("ep"): - logger.warning( - "재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id - ) - return "노선 제어점(BP·EP)이 없어 재계산할 수 없습니다." - - marks.append(("준비·사용자입력 회수", time.perf_counter())) - - # 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분. - request = redesign_solve_request(params, selection, surface_model_id) - # 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일. - solve_result: Any = await solve_route_record(project_id, request, generate_sections=False) - if isinstance(solve_result, JSONResponse): - # 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06 - # 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음). - try: - reason = bytes(solve_result.body).decode("utf-8", "replace")[:300] - except Exception: # noqa: BLE001 — 로그용이라 실패해도 흐름을 막지 않는다 - reason = "(본문 없음)" - logger.error( - "재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s reason=%s", - project_id, - solve_result.status_code, - reason, - ) - return f"노선 재계산 실패({solve_result.status_code}): {reason}" - new_route_id = int(solve_result.route_id) - marks.append(("노선 재계산(solve)", time.perf_counter())) - logger.info( - "재확정 체인 B05 재계산 완료: project_id=%s %s→%s", - project_id, - old_route_id, - new_route_id, - ) - - # 3) 옛 측점별 사용자 설계 이월은 **측점을 다시 만든 뒤**(아래 5) 한다. - # 여기서 하면 `_prepare_drainage_pipes_and_reprofile(refresh=True)` 가 측점을 - # 새로 만들며 덮어써 값이 사라진다(2026-09-06 실측: 다단 구간값 `extra_spans` 가 - # 노선 변경 뒤 0건이 됐음). 옛 설계는 `old_designs` 로 이미 손에 있다. - carried = 0 - - # 4) B05 확정 → B06 확정(옛 표준단면 설정 이월, 미지정 측점 기본값 채움). - # 재확정 후에도 stage 2·3은 IN_PROGRESS로 남겨 사용자 재검토를 받는다. - confirm_result = await confirm_latest_route(project_id, None, mark_stage_complete=False) - if isinstance(confirm_result, JSONResponse): - logger.error( - "재확정 체인 중단(B05 확정 실패): project_id=%s status=%s", - project_id, - confirm_result.status_code, - ) - return f"노선 확정 실패({confirm_result.status_code})" - marks.append(("B05 확정", time.perf_counter())) - # 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로 - # 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다. - # 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다. - seed = asdict(_section_options(request, old_section_options)) - seed.pop("extra_stations", None) - piped = await _prepare_drainage_pipes_and_reprofile( - project_id, new_route_id, refresh=True, stored_options=seed - ) - if not piped: - # 관 없이라도 종횡단은 세운다 — 새 노선엔 아직 종횡단이 없다(첫 생성을 건너뜀). - await regenerate_route_sections( - project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed - ) - - marks.append(("배수·관·측점 재생성", time.perf_counter())) - - if profile_target is not None: - from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target - - await apply_profile_target(project_id, new_route_id, profile_target) - marks.append(("균형 종단 반영", time.perf_counter())) - - old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {} - standard = old_options.get("standard_cross_section") or None - - # 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 **입력**만 누가거리로 얹고 전 측점을 - # 새 계획고로 다시 셈한다(PLAN 74-3). 결과 칸(계획고 · 면적 · design_line …)을 통째 - # 옮기면 옛 노선 단면이 새 노선에 남아 누가토량이 두 배가 됐다(74-1). 새 노선에 없는 - # 누가거리는 행을 만들지 않는다. - try: - inputs = { - round(float(record["chainage_m"]), 3): { - key: record["design"][key] - for key in CARRY_INPUT_KEYS - if record["design"].get(key) is not None - } - for record in old_designs - if isinstance(record.get("design"), dict) - } - async with pool.acquire() as connection: - new_longitudinal = await get_longitudinal_section( - connection, project_id, new_route_id - ) - long_path = str((new_longitudinal or {}).get("longitudinal_file_path") or "") - chainages = await asyncio.to_thread( - _rowless_station_chainages, project_root, long_path, [] - ) - entries = await asyncio.to_thread( - compute_default_designs, project_root, long_path, chainages, standard, inputs - ) - async with pool.acquire() as connection: - await connection.begin() - try: - carried = await merge_cross_section_designs( - connection, - route_id=new_route_id, - entries=entries, - replace=True, - project_id=project_id, - ) - await connection.commit() - except Exception: - await connection.rollback() - raise - except Exception: - logger.exception( - "재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id - ) - logger.info("재확정 체인 설계 이월: project_id=%s %d건", project_id, carried) - - marks.append(("설계 이월", time.perf_counter())) - - section_request = ( - SectionConfirmRequest(standard_cross_section=standard) if standard else None - ) - sections_result = await confirm_sections( - project_id, new_route_id, section_request, mark_stage_complete=False - ) - if isinstance(sections_result, JSONResponse): - logger.error( - "재확정 체인 중단(B06 확정 실패): project_id=%s status=%s", - project_id, - sections_result.status_code, - ) - return f"종횡단 확정 실패({sections_result.status_code})" - marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter())) - _log_steps("재확정 체인", marks) - logger.info( - "재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)", - project_id, - old_route_id, - new_route_id, - carried, - ) - return None - except Exception as exc: - logger.exception("재확정 체인 실패: project_id=%s", project_id) - return f"재계산 중 오류: {exc}" - finally: - if project_root is not None: - clear_designing(project_root) - # 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀 - from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute - - schedule_precompute(project_id) diff --git a/B03_FileInput/B03_FileInput_Service_SurfaceChange.py b/B03_FileInput/B03_FileInput_Service_SurfaceChange.py index 099f44d13..1687c7fea 100644 --- a/B03_FileInput/B03_FileInput_Service_SurfaceChange.py +++ b/B03_FileInput/B03_FileInput_Service_SurfaceChange.py @@ -1,8 +1,9 @@ """서피스가 바뀐 뒤 전처리 체인 다시(763-3 · 요구 605) — 업로드와 같은 길 하나. -부르는 곳 = B04 다른 서피스 [확정]. B05~B09 초기화 = 같은 지형 재업로드 길 -(`purge_project_outputs(keep_preprocess=True)` · 단계 2 이후 처음 · 초기값 스냅샷 버림) → -`run_auto_design_chain`(끝에 추천 미리 셈 · 실패 길에도). 전처리 결과(B04) · 확정은 그대로. +부르는 곳 = B04 다른 서피스 [확정] · B05 LAS 나중 올리기 뒤 전처리 다시(765-5). B05~B09 초기화 = +같은 지형 재업로드 길(`purge_project_outputs(keep_preprocess=True)` · 설계 벌 묶음까지 · 단계 2 이후 +처음 · 초기값 스냅샷 버림) → `run_preprocess_chain`(지형마다 한 벌 · 끝에 추천 미리 셈 · +실패 길에도). 전처리 결과(B04) · 확정은 그대로. """ import asyncio @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) async def run_surface_change_chain(project_id: UUID, surface_model_id: int | None) -> None: """B05~B09 를 지우고 자동 설계 체인을 새 확정 서피스로 다시 탐 — 실패는 로그만.""" from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path - from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain + from B03_FileInput.B03_FileInput_Service_Chain import run_preprocess_chain from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute from common_util.common_util_initial_snapshot import discard_initial_snapshot from common_util.common_util_project_reset import purge_project_outputs @@ -45,4 +46,4 @@ async def run_surface_change_chain(project_id: UUID, surface_model_id: int | Non schedule_precompute(project_id) return logger.info("서피스 바꿈 체인 — B05~B09 초기화 뒤 자동 설계: project_id=%s", project_id) - await run_auto_design_chain(project_id, surface_model_id=surface_model_id) + await run_preprocess_chain(project_id, surface_model_id) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 2957df93d..c48643228 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -217,11 +217,10 @@ async def trigger_wf1_analysis_and_email( # 횡단 설계 체인을 잇는다(2026-08-04 사용자 확정). 체인은 실패를 스스로 격리한다. design_summary: dict[str, Any] | None = None if auto_confirmed and AUTO_DESIGN_CHAIN_ENABLED: - from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain + from B03_FileInput.B03_FileInput_Service_Chain import run_preprocess_chain - design_summary = await run_auto_design_chain( - project_id, surface_model_id=confirmed_model_id - ) + # 지형마다 설계 한 벌(765-3) — 다른 지형 벌은 묶음 · 요약은 설계 지형 벌 + design_summary = await run_preprocess_chain(project_id, confirmed_model_id) # 알림 메일은 여기 한 통뿐이다 — 업로드 직후와 분석 직후로 나눠 두 통을 보내던 것을 # 초기 설계(B04~B06)까지 마친 시점의 통합 메일로 합쳤다(2026-08-08 사용자 지시). diff --git a/common_util/common_util_project_reset.py b/common_util/common_util_project_reset.py index b4e02fec9..5fe98a310 100644 --- a/common_util/common_util_project_reset.py +++ b/common_util/common_util_project_reset.py @@ -46,9 +46,17 @@ PREPROCESS_STAGE_DIR = "B04_PreProcess" _PREPROCESS_TABLES = frozenset({"surface_models", "processed_point_cloud"}) +#: 지형마다 설계 한 벌의 쉬는 벌 묶음(765-3) — 산출물이라 늘 함께 지움 +DESIGN_SETS_DIR = "design_sets" + + def purge_output_folders(project_root: Path, keep_preprocess: bool = False) -> list[str]: - """단계별 산출물 폴더를 비운다. 지운 폴더 이름을 돌려준다.""" + """단계별 산출물 폴더 · 설계 벌 묶음을 비운다. 지운 폴더 이름을 돌려준다.""" removed: list[str] = [] + sets = project_root / DESIGN_SETS_DIR + if sets.exists(): + shutil.rmtree(sets, ignore_errors=True) + removed.append(DESIGN_SETS_DIR) for name in OUTPUT_STAGE_DIRS: if keep_preprocess and name == PREPROCESS_STAGE_DIR: continue diff --git a/resources/tester/test_342_2_chain_once.py b/resources/tester/test_342_2_chain_once.py index 2a98d131d..857576ead 100644 --- a/resources/tester/test_342_2_chain_once.py +++ b/resources/tester/test_342_2_chain_once.py @@ -1,246 +1,22 @@ # -*- coding: utf-8 -*- -"""342-2 노선 [확인] 체인 — 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 는 [저장]·[확정] 끝. +"""342-2 자동 체인 재계산 한 번 · 3D 는 [저장]·[확정] 끝. -① 노선 [확인] 체인(`run_redesign_chain`)이 종횡단을 **한 번만** 만들고, 그 한 번이 옛 흐름의 - 마지막 생성(4c `regenerate_sections`)과 **같은 입력**(옵션 · 반폭 · 계획선 기준)을 받는다. - 옛 흐름은 solve 안 첫 생성이 남긴 옵션을 4c 가 읽었다 — 그 옵션을 체인이 넘겨 같게 둠. -② 배수유역이 실패해도 종횡단은 선다(첫 생성을 건너뛰었으므로 체인이 한 번 세움). ③ 자동 체인은 서버 재계산 · 3D 짓기를 따로 부르지 않음 — [확정](confirm_sections) 끝 한 번씩 · 재계산 → 짓기 차례 · [저장](save_sections)도 끝에 짓기. -DB · 엔진은 가짜로 바꿔 체인의 차례와 생성 입력만 잰다. +(①② 노선 [확인] 체인 `run_redesign_chain` 시험 셋은 그 함수를 걷어(765-3 · 부르는 곳 0) 함께 지움.) """ from __future__ import annotations -import asyncio import re import sys -from dataclasses import asdict from pathlib import Path -from types import SimpleNamespace -from uuid import uuid4 - -import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -import B03_FileInput.B03_FileInput_Repository as b03_repo # noqa: E402 -import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402 -import B04_PreProcess.B04_PreProcess_Router_Basins as basins # noqa: E402 -import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402 -import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402 -import B05_Profile.B05_Profile_Router as b05_router # noqa: E402 -import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 -import B06_Section.B06_Section_Repository_Bulk as b06_bulk # noqa: E402 -import B06_Section.B06_Section_Router as b06_router # noqa: E402 -import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402 -import B06_Section.B06_Section_Router_Design as b06_design # noqa: E402 -import common_util.common_util_initial_snapshot as snapshot # noqa: E402 -import common_util.common_util_storage as storage # noqa: E402 -import common_util.common_util_workflow_state as workflow # noqa: E402 -import config.config_db as config_db # noqa: E402 -from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402 -from fastapi.responses import JSONResponse # noqa: E402 - -# stage 2 params(요청 쪽) · 옛 노선 종단 옵션(DB 쪽)이 **엇갈리게** — 옛 흐름 순서를 못 지키면 값이 갈린다 -PARAMS = { - "filter_key": "csf", - "method": "dtm", - "smooth": False, - "points": {"bp": {"x": 0.0, "y": 0.0}, "ep": {"x": 100.0, "y": 0.0}, "cp": []}, - "options": {"grade_class": "trunk", "terrain_type": "normal"}, - "cross_half_width_m": 20.0, - "cross_sample_interval_m": 0.5, - "station_interval_m": None, - "long_sample_interval_m": None, -} -OLD_STORED = { - "station_interval_m": 25.0, - "cross_half_width_m": 30.0, - "cross_sample_interval_m": 1.0, - "long_sample_interval_m": 2.0, -} - - -class _Cursor: - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def execute(self, *args, **kwargs): - return None - - async def fetchone(self): - return None - - -class _Connection: - def cursor(self, *args): - return _Cursor() - - async def begin(self): - return None - - async def commit(self): - return None - - async def rollback(self): - return None - - -class _Acquire: - async def __aenter__(self): - return _Connection() - - async def __aexit__(self, *exc): - return False - - -class _Pool: - def acquire(self): - return _Acquire() - - -def _async(value): - async def fake(*args, **kwargs): - return value - - return fake - - -@pytest.fixture -def world(monkeypatch, tmp_path): - calls: dict = {"generate": [], "order": [], "latest_options": 0} - - async def solve(project_id, request, *, generate_sections=True): - calls["order"].append("solve") - calls["solve_generates"] = generate_sections - return SimpleNamespace(route_id=6, total_length_m=100.0) - - async def primary_region(project_id, refresh=False): - calls["order"].append("drainage") - return calls.get("region", {}) - - async def latest_options(connection, project_id): - calls["latest_options"] += 1 - return OLD_STORED - - def generate(project_root, route_data_path, filter_key, method, smooth, **kwargs): - calls["order"].append("generate") - calls["generate"].append(kwargs) - data = {"length_m": 100.0, "options": {}} - return { - "longitudinal": {"data": data, "file_path": "long.json"}, - "cross_sections": [], - "result": {"longitudinal": {}, "cross_sections": []}, - } - - async def confirm(project_id, route_id, request=None, mark_stage_complete=True): - calls["order"].append("confirm_sections") - return SimpleNamespace(route_id=route_id) - - state = {"stages": [{"stage_no": 2, "params": PARAMS}]} - monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool()) - monkeypatch.setattr(b05_repo, "get_latest_route", _async({"id": 5})) - monkeypatch.setattr(b03_repo, "get_project_storage_relative_path", _async("p")) - monkeypatch.setattr(storage, "resolve_stored_project_path", lambda path: str(tmp_path)) - monkeypatch.setattr(snapshot, "mark_designing", lambda root: None) - monkeypatch.setattr(snapshot, "clear_designing", lambda root: None) - monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda root: False) - monkeypatch.setattr(workflow, "get_workflow_state", _async(state)) - monkeypatch.setattr( - b06_repo, "get_longitudinal_section", _async({"data": {}, "longitudinal_file_path": "x"}) - ) - monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([])) - monkeypatch.setattr(b06_repo, "get_latest_section_options", latest_options) - monkeypatch.setattr(b05_router, "solve_route_record", solve) - monkeypatch.setattr(b05_router, "confirm_latest_route", _async({})) - monkeypatch.setattr(watershed, "get_primary_region", primary_region) - monkeypatch.setattr(basins, "put_pipe_points", _async({"saved_count": 2})) - monkeypatch.setattr(b06_confirm, "_rowless_station_chainages", lambda *a: []) - monkeypatch.setattr(b06_confirm, "confirm_sections", confirm) - monkeypatch.setattr(b06_design, "compute_default_designs", lambda *a: []) - monkeypatch.setattr(b06_bulk, "merge_cross_section_designs", _async(0)) - # 재생성 본체(B06_Section_Router) — DB 는 가짜 · 생성 입력만 잰다 - monkeypatch.setattr(b06_router, "get_db_pool", lambda: _Pool()) - monkeypatch.setattr( - b06_router, - "get_route_generation_source", - _async({"route_data_path": "route.geojson", "crs_epsg": 5186}), - ) - monkeypatch.setattr( - b06_router, - "get_surface_confirmation_params", - _async({"source_filter": "csf", "method": "dtm", "smooth": False}), - ) - monkeypatch.setattr(b06_router, "get_project_storage_relative_path", _async("p")) - monkeypatch.setattr(b06_router, "get_latest_section_options", latest_options) - monkeypatch.setattr(b06_router, "get_workflow_state", _async(state)) - monkeypatch.setattr(b06_router, "resolve_stored_project_path", lambda path: str(tmp_path)) - monkeypatch.setattr(b06_router, "run_section_generation", generate) - monkeypatch.setattr(b06_router, "delete_sections_for_route", _async(None)) - monkeypatch.setattr(b06_router, "create_longitudinal_section", _async(None)) - monkeypatch.setattr(b06_router, "insert_cross_sections", _async(None)) - monkeypatch.setattr(b06_router, "attach_saved_shapes", lambda *a: None) - return calls - - -def _old_flow_options(): - """옛 흐름 — solve 첫 생성 옵션(요청 → 옛 저장 → 기본)을 4c 가 DB 에서 읽어 다시 고른 값.""" - request = RouteSolveRequest( - filter_key="csf", - method="dtm", - smooth=False, - algorithm="as_planned", - bp=PARAMS["points"]["bp"], - ep=PARAMS["points"]["ep"], - cross_half_width_m=PARAMS["cross_half_width_m"], - cross_sample_interval_m=PARAMS["cross_sample_interval_m"], - ) - first = b05_router._section_options(request, OLD_STORED) - stored_by_first = {**asdict(first), "extra_stations": []} - return b06_router._regeneration_options( - stored_by_first, PARAMS, float(stored_by_first["cross_half_width_m"]) - ) - - -def test_종횡단은_한_번_관_뒤에(world): - failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) - assert failure is None - assert world["solve_generates"] is False - assert len(world["generate"]) == 1 - order = world["order"] - assert order.index("solve") < order.index("drainage") < order.index("generate") - assert order.index("generate") < order.index("confirm_sections") - - -def test_한_번이_옛_마지막_생성과_같은_입력(world): - asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) - got = world["generate"][0] - expected = _old_flow_options() - assert got["options"] == expected - # 옛 노선 저장 반폭(30)이 아니라 요청 반폭(20) — 첫 생성이 남기던 값 - assert got["options"].cross_half_width_m == 20.0 - assert got["options"].cross_sample_interval_m == 0.5 - assert got["options"].station_interval_m == 25.0 - assert got["grade_options"] == b06_router._regeneration_grade_options(PARAMS) - assert "grade_overrides" not in got # 옛 4c 도 안 넘김 — 저장본 grade_overrides 빈 값 그대로 - # 생성 쪽은 DB 최신 종단(=옛 노선)을 다시 읽지 않음 — 체인이 넘긴 옵션 - assert world["latest_options"] == 1 # 체인이 옛 노선 옵션을 읽은 한 번뿐 - - -def test_배수유역이_실패해도_종횡단은_선다(world): - world["region"] = JSONResponse(status_code=500, content={}) - failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {})) - assert failure is None - assert len(world["generate"]) == 1 - assert world["generate"][0]["options"] == _old_flow_options() - def _body(source: str, name: str) -> str: start = source.index(f"async def {name}(") diff --git a/resources/tester/test_351_1_recommend_gaps.py b/resources/tester/test_351_1_recommend_gaps.py index f504efec2..61158ab4e 100644 --- a/resources/tester/test_351_1_recommend_gaps.py +++ b/resources/tester/test_351_1_recommend_gaps.py @@ -90,10 +90,12 @@ def test_기본값_판정() -> None: def test_두_체인_끝에서_미리_셈_예약() -> None: source = Path("B03_FileInput/B03_FileInput_Service_Chain.py").read_text(encoding="utf-8") - redesign = source[source.index("async def run_redesign_chain(") :] - upload = source[: source.index("async def run_redesign_chain(")] - # 두 체인 모두 finally — 실패 · 건너뜀 길에도 예약(763-3 · 실행 시험 = test_763_3) - for body in (upload, redesign): + start = source.index("async def run_auto_design_chain(") + upload = source[start : source.index("async def run_preprocess_chain(")] + preprocess = source[source.index("async def run_preprocess_chain(") :] + # 두 체인 모두 finally — 실패 · 건너뜀 길에도 예약(763-3 · 실행 시험 = test_763_3) · + # 옛 재확정 체인(run_redesign_chain)은 걷음(765-3) · 두 벌 체인이 자리를 이음 + for body in (upload, preprocess): assert "schedule_precompute(project_id)" in body.split(" finally:", 1)[1] b01 = Path("B01_Dashboard/B01_Dashboard_Router.py").read_text(encoding="utf-8") assert "refresh_expected_route(" in b01 diff --git a/resources/tester/test_74_route_confirm_initial.py b/resources/tester/test_74_route_confirm_initial.py index 089e1dbfa..df899a971 100644 --- a/resources/tester/test_74_route_confirm_initial.py +++ b/resources/tester/test_74_route_confirm_initial.py @@ -32,9 +32,10 @@ def test_이월은_사용자_입력만() -> None: def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None: - source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text(encoding="utf-8") + # 노선 저장 체인(`run_route_save_chain`)이 이월 자리 — 옛 재확정 체인은 걷음(765-3) + source = (ROOT / "B05_Profile" / "B05_Profile_Route_Save.py").read_text(encoding="utf-8") assert 'record["design"])' not in source # 옛 (누가거리, design 통째) 이월 - assert "compute_default_designs, project_root, long_path, chainages, standard, inputs" in ( + assert "compute_default_designs, project_root, relative, chainages, standard, inputs" in ( " ".join(source.split()) ) diff --git a/resources/tester/test_763_3_surface_change.py b/resources/tester/test_763_3_surface_change.py index 7c574173e..fb030451c 100644 --- a/resources/tester/test_763_3_surface_change.py +++ b/resources/tester/test_763_3_surface_change.py @@ -188,7 +188,7 @@ def test_서피스_바꿈_체인_차례(tmp_path, monkeypatch) -> None: log.append(("stages", first_stage)) async def auto(_pid, surface_model_id=None): - log.append(("auto", surface_model_id)) + log.append(("preprocess", surface_model_id)) monkeypatch.setattr(db, "get_db_pool", lambda: _Pool([])) monkeypatch.setattr(repo, "get_project_storage_relative_path", _async("x")) @@ -196,10 +196,10 @@ def test_서피스_바꿈_체인_차례(tmp_path, monkeypatch) -> None: monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda _r: log.append("snapshot")) monkeypatch.setattr(reset, "purge_project_outputs", purge) monkeypatch.setattr(state, "reset_stages_after_input_change", stages) - monkeypatch.setattr(chain, "run_auto_design_chain", auto) + monkeypatch.setattr(chain, "run_preprocess_chain", auto) monkeypatch.setattr(router, "schedule_precompute", lambda pid: log.append("precompute")) asyncio.run(change.run_surface_change_chain(uuid4(), 8)) - assert log == ["snapshot", ("purge", True), ("stages", 2), ("auto", 8)] + assert log == ["snapshot", ("purge", True), ("stages", 2), ("preprocess", 8)] async def broken(*_a, **_k): raise RuntimeError("db") @@ -210,7 +210,7 @@ def test_서피스_바꿈_체인_차례(tmp_path, monkeypatch) -> None: assert log == ["snapshot", "precompute"] # 초기화 실패 = 체인 없음 · 추천만 새 서피스로 -@pytest.mark.parametrize("name", ["run_auto_design_chain", "run_redesign_chain"]) +@pytest.mark.parametrize("name", ["run_auto_design_chain", "run_preprocess_chain"]) def test_체인_실패_길에도_미리_셈(monkeypatch, name) -> None: import B03_FileInput.B03_FileInput_Service_Chain as chain import B05_Profile.B05_Profile_Router_RouteInitial as router @@ -224,8 +224,7 @@ def test_체인_실패_길에도_미리_셈(monkeypatch, name) -> None: monkeypatch.setattr(db, "get_db_pool", lambda: _Broken()) monkeypatch.setattr(router, "schedule_precompute", scheduled.append) project = uuid4() - args = (project,) if name == "run_auto_design_chain" else (project, 1, {}) - asyncio.run(getattr(chain, name)(*args)) + asyncio.run(getattr(chain, name)(project)) assert scheduled == [project] diff --git a/resources/tester/test_765_design_sets.py b/resources/tester/test_765_design_sets.py index 0cca50bba..3df33836f 100644 --- a/resources/tester/test_765_design_sets.py +++ b/resources/tester/test_765_design_sets.py @@ -411,3 +411,109 @@ def test_맞바꿈_실패면_통째_되돌림(monkeypatch, tmp_path: Path) -> No assert _tree(tmp_path) == before_files assert sets.read_meta(tmp_path, "sheet") == sheet_meta # 묶음도 그대로 assert not (tmp_path / SETS_DIR / "las").exists() + + +# ── 765-3 전처리 체인 = 두 벌 ───────────────────────────────────────────── + + +def _chain_world(monkeypatch, tmp_path: Path, *, other, built_other=True): + import B03_FileInput.B03_FileInput_Repository as repo + import B03_FileInput.B03_FileInput_Service_Chain as chain + import B05_Profile.B05_Profile_Router_RouteInitial as router + import common_util.common_util_storage as storage + import config.config_db as config_db + + log: list = [] + expected = tmp_path / "B05_Profile" / "route" / "expected_route.csv" + + async def auto(pid, model_id=None, inner=False): + log.append(("auto", model_id, inner)) + expected.parent.mkdir(parents=True, exist_ok=True) + expected.write_text(str(model_id), encoding="utf-8") # 체인이 처음 쓰는 예상노선 + return {"route_id": 1} if (built_other or len(log) > 2) else None + + async def switch(_c, _pid, terrain): + log.append(("switch", terrain)) + return 6 if terrain == "sheet" else 5 + + async def park(_c, _root, _pid, fingerprint): + log.append(("park", fingerprint["terrain"])) + + async def clear(_c, _root, _pid): + log.append("clear") + + async def fingerprint(_c, _pid, _root, terrain): + return {**FP, "terrain": terrain} + + async def params(_c, _pid): + return {"source_filter": "classification"} + + monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool(_Conn(sqlite3.connect(":memory:")))) + monkeypatch.setattr(repo, "get_project_storage_relative_path", lambda *_a: _done("p")) + monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path)) + import common_util.common_util_surface_confirmation as confirmation + + monkeypatch.setattr(confirmation, "get_surface_confirmation_params", params) + monkeypatch.setattr(sets, "other_terrain", lambda _p, _r: other) + monkeypatch.setattr(sets, "switch_terrain", switch) + monkeypatch.setattr(sets, "park_active_set", park) + monkeypatch.setattr(sets, "clear_active_set", clear) + monkeypatch.setattr(sets, "set_fingerprint", fingerprint) + monkeypatch.setattr(chain, "run_auto_design_chain", auto) + monkeypatch.setattr(router, "schedule_precompute", lambda pid: log.append("precompute")) + return chain, log, expected + + +async def _done(value): + return value + + +def test_전처리_체인_다른_지형_먼저_떠_두고_설계_지형(monkeypatch, tmp_path: Path) -> None: + chain, log, expected = _chain_world(monkeypatch, tmp_path, other="sheet") + (tmp_path / SETS_DIR / "las").mkdir(parents=True) # 옛 묶음 = 버림 + summary = asyncio.run(chain.run_preprocess_chain(PID, 5)) # type: ignore[arg-type] + assert summary == {"route_id": 1} + assert log == [ + ("switch", "sheet"), + ("auto", 6, True), + ("park", "sheet"), + ("switch", "las"), + ("auto", 5, True), + "precompute", + ] + assert expected.read_text(encoding="utf-8") == "5" # 예상노선 = 설계 지형 벌이 씀 + assert not (tmp_path / SETS_DIR / "las").exists() + assert not (tmp_path / "initial_design.lock").exists() + + +def test_전처리_체인_다른_지형_없으면_한_벌(monkeypatch, tmp_path: Path) -> None: + chain, log, _ = _chain_world(monkeypatch, tmp_path, other=None) + asyncio.run(chain.run_preprocess_chain(PID, 5)) # type: ignore[arg-type] + assert log == [("auto", 5, True), "precompute"] + meta = sets.read_meta(tmp_path, "sheet") + assert meta["built"] is False and "sheet" in meta["reason"] + + +def test_전처리_체인_다른_벌_실패해도_설계_지형(monkeypatch, tmp_path: Path) -> None: + chain, log, _ = _chain_world(monkeypatch, tmp_path, other="sheet", built_other=False) + summary = asyncio.run(chain.run_preprocess_chain(PID, 5)) # type: ignore[arg-type] + assert summary == {"route_id": 1} + assert log == [ + ("switch", "sheet"), + ("auto", 6, True), + "clear", + ("switch", "las"), + ("auto", 5, True), + "precompute", + ] + assert sets.read_meta(tmp_path, "sheet")["built"] is False + + +def test_비우기에_묶음_포함(tmp_path: Path) -> None: + from common_util.common_util_project_reset import purge_output_folders + + (tmp_path / SETS_DIR / "sheet" / "files").mkdir(parents=True) + (tmp_path / "B04_PreProcess" / "models").mkdir(parents=True) + removed = purge_output_folders(tmp_path, keep_preprocess=True) + assert SETS_DIR in removed and not (tmp_path / SETS_DIR).exists() + assert (tmp_path / "B04_PreProcess" / "models").is_dir() diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 8b8f4fd80..84c1916ca 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -13,7 +13,6 @@ """ import math -import re import sys from pathlib import Path @@ -192,13 +191,13 @@ def test_토사에서_켠_2단_절토가_왕복해도_남는다(): # ── ⑤ 노선 변경 때 옛 사용자 설계가 덮여 사라지던 것 (`908c75d5`) ─────────────── # 이월을 측점 재생성 **앞**에서 하면 재생성이 덮어썼다. 순서가 곧 고침이라 순서를 지킨다. def test_이월은_측점_재생성_뒤에_한다(): - source = (PROJECT_ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text( + # 노선 저장 체인이 이월 자리 — 옛 재확정 체인(run_redesign_chain)은 걷음(765-3) + source = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_Route_Save.py").read_text( encoding="utf-8" ) - rebuild = re.search( - r"_prepare_drainage_pipes_and_reprofile\(\s*project_id, new_route_id", source - ).start() - carry = source.index("merge_cross_section_designs(") + body = source[source.index("async def run_route_save_chain(") :] + rebuild = body.index("regenerate_route_sections(") + carry = body.index("_carry_cross(") assert carry > rebuild, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다"