fix(B05 지반고): 342-4 지반고 값 갈림 둘 맞춤 — 노선 꼭짓점 z · 종단 미리보기 = 공용 1 m 쌍선형 표본기

- 노선 꼭짓점 z: 한 칸 값 · 빈칸 0.0 대신 build_surface_sampler · 빈칸 · 밖은 이웃 정점 누가거리 보간 · 모두 밖이면 멈춤
- 종단 미리보기 · [확인] 균형 목표: 2 m 비용면 대신 1 m 표본기(추천 점수 비용면은 그대로)
- 시험 test_342_4_ground_unify.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
2026-10-16 11:01:38 +09:00
co-authored by Claude Opus 5.5
parent 2879f9bea9
commit a22b72fb10
4 changed files with 116 additions and 34 deletions
+20 -22
View File
@@ -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)
@@ -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,
@@ -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(
@@ -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