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:
@@ -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
|
||||
Reference in New Issue
Block a user