From a22b72fb105af7ca42bf106a346bfc6b3f83a25b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:01:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=20=EC=A7=80=EB=B0=98=EA=B3=A0):=20342-4?= =?UTF-8?q?=20=EC=A7=80=EB=B0=98=EA=B3=A0=20=EA=B0=92=20=EA=B0=88=EB=A6=BC?= =?UTF-8?q?=20=EB=91=98=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=BC=AD=EC=A7=93=EC=A0=90=20z=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=3D?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=201=20m=20=EC=8C=8D=EC=84=A0=ED=98=95=20?= =?UTF-8?q?=ED=91=9C=EB=B3=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노선 꼭짓점 z: 한 칸 값 · 빈칸 0.0 대신 build_surface_sampler · 빈칸 · 밖은 이웃 정점 누가거리 보간 · 모두 밖이면 멈춤 - 종단 미리보기 · [확인] 균형 목표: 2 m 비용면 대신 1 m 표본기(추천 점수 비용면은 그대로) - 시험 test_342_4_ground_unify.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_Engine_AsPlanned.py | 42 ++++++------ ...B05_Profile_Engine_RouteInitial_Profile.py | 30 ++++++--- .../B05_Profile_Router_RouteInitial.py | 12 +++- resources/tester/test_342_4_ground_unify.py | 66 +++++++++++++++++++ 4 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 resources/tester/test_342_4_ground_unify.py diff --git a/B05_Profile/B05_Profile_Engine_AsPlanned.py b/B05_Profile/B05_Profile_Engine_AsPlanned.py index d176d08cb..2f54aa5dd 100644 --- a/B05_Profile/B05_Profile_Engine_AsPlanned.py +++ b/B05_Profile/B05_Profile_Engine_AsPlanned.py @@ -32,28 +32,26 @@ logger = logging.getLogger(__name__) _MODELS_SUBDIR = "B04_PreProcess/models" -def _elevation_lookup( - project_root: Path, filter_key: str, method: str, smooth: bool -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """지표면 격자(x, y, z) — 노선 정점의 표고를 뜨는 데 쓴다.""" - from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _sample_surface_on_grid +def _vertex_z( + project_root: Path, + filter_key: str, + method: str, + smooth: bool, + vertices: list[tuple[float, float]], +) -> list[float]: + """노선 정점 표고 — 측점 생성과 같은 공용 표본기(1 m 쌍선형 · 네 칸 유효). - models_dir = Path(project_root) / _MODELS_SUBDIR - x_coords, y_coords, dtm_z, _valid = _load_dtm_grid(models_dir, filter_key, smooth) - surface_z = _sample_surface_on_grid( - models_dir, filter_key, method, smooth, x_coords, y_coords, dtm_z - ) - return x_coords, y_coords, surface_z + 빈칸 · 격자 밖 정점은 유효한 이웃 정점 값을 누가거리로 보간(0.0 을 흘리지 않음) · + 유효한 정점이 없으면 ValueError.""" + from common_util.common_util_surface_sampler import build_surface_sampler - -def _z_at( - x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray, x: float, y: float -) -> float: - """격자에서 가장 가까운 칸의 표고. 격자 밖이면 가장자리 값.""" - col = int(np.clip(np.searchsorted(x_coords, x), 0, len(x_coords) - 1)) - row = int(np.clip(np.searchsorted(y_coords, y), 0, len(y_coords) - 1)) - value = float(z_grid[row, col]) - return value if math.isfinite(value) else 0.0 + sampler = build_surface_sampler(Path(project_root) / _MODELS_SUBDIR, filter_key, method, smooth) + xy = np.asarray(vertices, dtype=np.float64) + z, valid = sampler.sample_xy(xy) + if not valid.any(): + raise ValueError("계획노선 정점이 모두 지표면 밖입니다.") + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) + return np.interp(chain, chain[valid], z[valid]).tolist() def solve_as_planned( @@ -67,8 +65,8 @@ def solve_as_planned( """계획노선 정점을 그대로 노선으로 삼는다. 반환 꼴은 솔버와 같다.""" if len(vertices) < 2: raise ValueError("계획노선 정점이 2개 미만입니다.") - x_coords, y_coords, z_grid = _elevation_lookup(project_root, filter_key, method, smooth) - polyline = [[float(x), float(y), _z_at(x_coords, y_coords, z_grid, x, y)] for x, y in vertices] + heights = _vertex_z(project_root, filter_key, method, smooth, vertices) + polyline = [[float(x), float(y), z] for (x, y), z in zip(vertices, heights)] max_uphill = float(options.get("max_uphill_grade") or 0.26) max_downhill = float(options.get("max_downhill_grade") or 0.26) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index de8e9fc8f..095e63c8b 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -45,6 +45,7 @@ from config.config_system_design import ( if TYPE_CHECKING: from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain + from common_util.common_util_surface_sampler import SurfaceElevationSampler STATION_M = 20.0 # 측점 간격 — 추천 비교값(`..._Metrics.STATION_M`)과 같음 TAIL_MERGE_M = 5.0 # 종점과 이보다 가까운 정규 측점은 뺌(짧은 끝 구간의 기울기 튐 방지) @@ -64,9 +65,25 @@ PIPE_SNAP_M = 5.0 # 관 자리가 노선에서 이 안이면 그 노선의 관 GRADE_EPS = 1e-6 +def _sample( + terrain: Terrain | SurfaceElevationSampler, xy: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """(N, 2) 점의 (표고, 유효) — 표본기면 공용 1 m 쌍선형(종단 미리보기 · [확인]) · + `Terrain` 이면 추천 비용면(2 m · 빈칸 평균 표고 · 유효는 가까운 칸 — 추천 점수 전용).""" + if hasattr(terrain, "sample_xy"): + return terrain.sample_xy(xy) + lookup = xy[:, ::-1] + grid = (terrain.y, terrain.x) + z = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=np.nan)(lookup) + valid = RegularGridInterpolator( + grid, terrain.valid.astype(np.float64), method="nearest", bounds_error=False, fill_value=0 + )(lookup) + return z, valid > 0.5 + + def route_sections( vertices: list[tuple[float, float]], - terrain: Terrain, + terrain: Terrain | SurfaceElevationSampler, design_curves: list[dict[str, Any]] | None = None, ) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]: """측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭. @@ -88,14 +105,9 @@ def route_sections( center = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]) offsets = np.arange(-CROSS_WIDE_M, CROSS_WIDE_M + 1e-9, CROSS_STEP_M) points = center[:, None, :] + left[:, None, :] * offsets[None, :, None] - lookup = points.reshape(-1, 2)[:, ::-1] - grid = (terrain.y, terrain.x) - z = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=np.nan)(lookup) - valid = RegularGridInterpolator( - grid, terrain.valid.astype(np.float64), method="nearest", bounds_error=False, fill_value=0 - )(lookup) + z, valid = _sample(terrain, points.reshape(-1, 2)) z = z.reshape(len(at), len(offsets)) - ok = (valid.reshape(z.shape) > 0.5) & np.isfinite(z) + ok = valid.reshape(z.shape) & np.isfinite(z) middle = int(np.argmin(np.abs(offsets))) ground = np.where(ok[:, middle], z[:, middle], np.nan) if np.isnan(ground).all(): @@ -405,7 +417,7 @@ def balanced_profile( def route_profile( vertices: list[tuple[float, float]], - terrain: Terrain, + terrain: Terrain | SurfaceElevationSampler, *, max_grade: float, design_speed: int, diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 5bb6f494e..53091ec56 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -284,12 +284,13 @@ async def balanced_profile_of( `terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — None = 지금 설계 지형([확인] 체인). """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct - from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile + from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR from common_util.common_util_surface_confirmation import ( analysis_terrain_params, get_surface_confirmation_params, ) + from common_util.common_util_surface_sampler import build_surface_sampler grade_class, design_speed, terrain_type = await replan._road_settings(project_id) max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0 @@ -300,8 +301,13 @@ async def balanced_profile_of( selection = await get_surface_confirmation_params(connection, str(project_id)) selection = analysis_terrain_params(selection, terrain) try: - surface, _streams = await asyncio.to_thread( - load_terrain_and_streams, project_root, selection + # 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님) — 띠 값 = [확인] 뒤 값 + surface = await asyncio.to_thread( + build_surface_sampler, + project_root / _MODELS_SUBDIR, + str(selection.get("source_filter")), + str(selection.get("method") or "dtm"), + bool(selection.get("smooth")), ) profile = await asyncio.to_thread( functools.partial( diff --git a/resources/tester/test_342_4_ground_unify.py b/resources/tester/test_342_4_ground_unify.py new file mode 100644 index 000000000..49cce81e0 --- /dev/null +++ b/resources/tester/test_342_4_ground_unify.py @@ -0,0 +1,66 @@ +"""342-4 지반고 값 갈림 맞춤 — 노선 꼭짓점 z · 종단 미리보기가 공용 1 m 표본기와 같은 값. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_342_4_ground_unify.py -q +""" + +from pathlib import Path + +import numpy as np +import pytest + +from B05_Profile.B05_Profile_Engine_AsPlanned import solve_as_planned +from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_sections +from common_util.common_util_surface_sampler import DtmGridSampler, build_surface_sampler + + +def _grid(tmp_path: Path) -> Path: + """1 m 남짓 고르지 않은 축 · 기울어진 면 · 가운데 빈칸 한 덩이.""" + x = np.cumsum(np.r_[0.0, np.full(120, 1.0) + 0.03 * np.sin(np.arange(120))]) + y = np.cumsum(np.r_[0.0, np.full(80, 1.0) + 0.02 * np.cos(np.arange(80))]) + xx, yy = np.meshgrid(x, y) + z = 100.0 + 0.1 * xx + 0.05 * yy + np.sin(xx / 7.0) + valid = np.ones(z.shape, dtype=bool) + valid[38:42, 58:62] = False + models = tmp_path / "B04_PreProcess" / "models" + models.mkdir(parents=True) + np.savez(models / "dtm_classification.npz", x=x, y=y, z=z, valid_mask=valid) + return models + + +def test_꼭짓점_z_는_공용_표본기_값(tmp_path: Path) -> None: + models = _grid(tmp_path) + vertices = [(5.3, 10.7), (40.2, 30.9), (60.0, 40.0), (100.4, 60.2)] # 셋째 = 빈칸 + result = solve_as_planned(tmp_path, "classification", False, vertices, {}) + z = [p[2] for p in result["polyline"]] + expect, valid = build_surface_sampler(models, "classification", "dtm", False).sample_xy( + np.asarray(vertices) + ) + assert valid.tolist() == [True, True, False, True] + assert np.allclose([z[0], z[1], z[3]], expect[[0, 1, 3]], atol=1e-9) + # 빈칸은 0.0 이 아니라 이웃 정점 사이 누가거리 보간 + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(vertices, axis=0).T))]) + assert z[2] == pytest.approx(np.interp(chain[2], chain[[1, 3]], expect[[1, 3]])) + assert min(z) > 50.0 + + +def test_꼭짓점_모두_밖이면_멈춤(tmp_path: Path) -> None: + _grid(tmp_path) + with pytest.raises(ValueError): + solve_as_planned(tmp_path, "classification", False, [(-50.0, -50.0), (-20.0, -40.0)], {}) + + +def test_종단_미리보기_지반은_1m_쌍선형(tmp_path: Path) -> None: + models = _grid(tmp_path) + sampler = DtmGridSampler.from_npz(models / "dtm_classification.npz") + vertices = [(10.0, 40.0), (110.0, 40.0)] # 가운데 빈칸을 지남 + at, ground, sections, _modes, _curves = route_sections(vertices, sampler) + for chain, row in zip(at, sections): + cx = 10.0 + chain + offsets = np.array([s["offset_m"] for s in row]) + z, valid = sampler.sample_xy(np.column_stack([np.full(offsets.size, cx), 40.0 + offsets])) + assert [s["valid"] for s in row] == valid.tolist() + got = np.array([np.nan if s["elevation_m"] is None else s["elevation_m"] for s in row]) + assert np.allclose(got[valid], z[valid], atol=1e-9) + assert all(s["elevation_m"] is None for s, ok in zip(row, valid) if not ok) + # 중심 빈칸 측점의 지반고는 이웃 측점 보간 — 0.0 · 평균 표고로 안 메움 + assert np.isfinite(ground).all() and ground.min() > 50.0