feat(530-1 · 530-2): D 예정노선 지형 받기 · 2단계 분석 서버 — B05 엔진 인자 셋(models_dir · cache_dir · 복도 반폭) · D03 지형 서비스 · 분석 · API

- B05 지형 로더 models_dir · cache_dir=None · 비용면 cache_dir=None · 탐색 복도 corridor_m(기본 100 m · 기본이면 묶음 열쇠 그대로)
- D03_PlanAnalysis_Terrain — 지점 + 1 km 표본 3×3 도엽 · prune 없음 · 병합 옛 ∪ 새 · 위성 합집합 · 연도별 models_<연도> DEM · plan.prj
- D03_PlanAnalysis_Analyze — 연도 구간마다 갈래 넷 · 법정 기준 · 누가 이어 셈 · 파생 저장본(입력 지문)
- D03_PlanAnalysis_Router — POST plan/terrain · plan/analyze(뒤에서 · 진행률) · GET plan/progress · plan/analysis · PUT [저장]
- 지킴 시험 test_530_1_plan_terrain · test_530_2_plan_analyze · test_530_2_plan_router

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 19:38:40 +09:00
co-authored by Claude Opus 5.5
parent bc39953fbd
commit 17534a01ad
10 changed files with 1070 additions and 10 deletions
+115
View File
@@ -0,0 +1,115 @@
"""D 예정노선 지형 받기(PLAN 530-1) — 가짜 내려받기 · 옛 도엽 유지 · 새 도엽만 · 빠짐 없음.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q
"""
import json
import math
from pathlib import Path
import pytest
from pyproj import Transformer
from B04_PreProcess import B04_PreProcess_Engine_SheetStore as store_mod
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheets_for_points
from common_util.common_util_crs import resolve_project_crs
from config.config_system_terrain import SHEET_SURFACE_MARGIN_M
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
EPSG = 5186
A, B, C = (230000.0, 450000.0), (231500.0, 451200.0), (238000.0, 455000.0)
@pytest.fixture
def fake(tmp_path, monkeypatch):
seen = {"download": [], "merge": [], "satellite": [], "dem": []}
def download_sheet(store_dir, sheet_no, version=2):
seen["download"].append(sheet_no)
store_dir = Path(store_dir)
store_dir.mkdir(parents=True, exist_ok=True)
(store_dir / f"{sheet_no}.zip").write_bytes(b"PK")
index = store_mod._load_index(store_dir)
index["sheets"][sheet_no] = {"file": f"{sheet_no}.zip"}
store_mod._save_index(store_dir, index)
def merge(store_dir, sheets, output_dir, layers=None):
seen["merge"].append(list(sheets))
(output_dir / "도엽_등고선.geojson").write_text("{}", encoding="utf-8")
return {}
def satellite(prj, bounds, output_dir, layer_name="Satellite", ext="jpeg"):
seen["satellite"].append(bounds)
meta = {"x_min": bounds["x"][0], "x_max": bounds["x"][1]}
meta.update({"y_min": bounds["y"][0], "y_max": bounds["y"][1]})
(output_dir / "vworld_satellite_meta.json").write_text(json.dumps(meta), "utf-8")
return output_dir / "vworld_satellite.png"
def dem(root, processed, models_dir, route_xy, crs, methods=None):
seen["dem"].append((models_dir.name, route_xy.tolist(), crs))
return [{"model_file_path": f"B04_PreProcess/{models_dir.name}/dtm.npz"}]
monkeypatch.setattr(store_mod, "download_sheet", download_sheet)
monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge)
monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite)
monkeypatch.setattr(terrain, "build_sheet_surface_model", dem)
return tmp_path, seen
def _zips(root):
return sorted(p.stem for p in store_mod.get_project_map_sheets_dir(root).glob("*.zip"))
def test_more_points_keep_old_sheets_and_fetch_only_new(fake) -> None:
root, seen = fake
first = terrain.ensure_plan_terrain(root, [{"year": 2026, "start": A, "end": B}], EPSG)
old = _zips(root)
assert sorted(seen["download"]) == old == sorted(first["sheets"]["downloaded"])
assert first["satellite"] == "done" and list(first["years"]) == [2026]
# 작업 좌표계 .prj — B 좌표계 창구가 읽음
assert resolve_project_crs(root) == f"EPSG:{EPSG}"
seen["download"].clear()
segs = [{"year": 2026, "start": A, "end": B}, {"year": 2027, "start": B, "end": C}]
second = terrain.ensure_plan_terrain(root, segs, EPSG)
now = _zips(root)
assert set(old) <= set(now) # 옛 도엽 그대로(prune 없음)
assert sorted(seen["download"]) == sorted(set(now) - set(old)) # 새 도엽만 받음
assert seen["merge"][-1] == now # 병합 = 옛 ∪ 새
# 위성 = 옛 범위 ∪ 새 범위
first_box, union = seen["satellite"][0], seen["satellite"][-1]
assert union["x"][0] <= first_box["x"][0] and union["x"][1] >= first_box["x"][1]
assert union["y"][0] <= first_box["y"][0] and union["y"][1] >= first_box["y"][1]
assert [d[0] for d in seen["dem"][-2:]] == ["models_2026", "models_2027"]
assert second["sheets"]["failed"] == {}
# 같은 지점 다시 — 받기 0 · 위성 건너뜀 · 병합 열쇠 같음
seen["download"].clear()
merges = len(seen["merge"])
third = terrain.ensure_plan_terrain(root, segs, EPSG)
assert seen["download"] == [] and third["satellite"] == "skip"
assert len(seen["merge"]) == merges
def test_far_points_leave_no_gap(fake) -> None:
root, _ = fake
far = (A[0] + 9000.0, A[1] + 7000.0) # 11 km — 3×3 두 벌이 안 닿는 거리
got = terrain.ensure_plan_terrain(root, [{"year": 2026, "start": A, "end": far}], EPSG)
to_wgs = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True)
on_line = []
for i in range(201):
lon, lat = to_wgs.transform(
A[0] + (far[0] - A[0]) * i / 200, A[1] + (far[1] - A[1]) * i / 200
)
on_line.append((lat, lon))
assert set(sheets_for_points(on_line)) <= set(got["sheets"]["merged"])
def test_dem_outline_covers_corridor() -> None:
box = terrain.dem_outline(A, B)
reach = terrain.PLAN_CORRIDOR_M + terrain.DEM_EXTRA_M
assert box[0][0] - SHEET_SURFACE_MARGIN_M <= min(A[0], B[0]) - reach + 1e-9
assert box[1][1] + SHEET_SURFACE_MARGIN_M >= max(A[1], B[1]) + reach - 1e-9
samples = terrain.segment_samples(A, C)
steps = [math.dist(p, q) for p, q in zip(samples, samples[1:])]
assert samples[0] == A and samples[-1] == C and max(steps) <= terrain.SAMPLE_STEP_M
+111
View File
@@ -0,0 +1,111 @@
"""D 예정노선 2단계 분석(PLAN 530-2) — 합성 지형 두 구간 · 경계 · 누가 · 저장본 · 복도 인자.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_analyze.py -q
"""
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import CORRIDOR_M, SearchGrid
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
CRITERIA = {"max_grade": 0.14, "min_radius_m": 12.0, "design_speed": 20}
SEGMENTS = [
{"year": 2026, "start": (60.0, 200.0), "end": (560.0, 230.0)},
{"year": 2027, "start": (560.0, 230.0), "end": (1040.0, 160.0)},
]
@pytest.fixture(scope="module")
def hill() -> Terrain:
x = np.arange(0.0, 1102.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
z = 30.0 * np.exp(-((xx - 300.0) ** 2 + (yy - 210.0) ** 2) / 80.0**2) + 0.02 * xx
return Terrain(x, y, z, np.ones(xx.shape, dtype=bool))
@pytest.fixture
def run(tmp_path, hill):
_POOLS.clear()
calls = []
def loader(root, selection, models_dir, cache_dir):
calls.append((models_dir.name, cache_dir.name))
return hill, []
def go(criteria=CRITERIA, corridor_m=150.0):
return analyze.analyze_plan(
tmp_path, SEGMENTS, criteria, loader=loader, corridor_m=corridor_m
)
go.calls = calls
yield go
_POOLS.clear()
def test_boundary_chainage_and_line(run) -> None:
results = run()
# 연도 구간마다 제 DEM · 제 비용면 캐시
assert run.calls == [("models_2026", "cost_2026"), ("models_2027", "cost_2027")]
for seg, result in zip(SEGMENTS, results):
assert set(result["routes"]) == set(analyze.PLAN_MODES)
for route in result["routes"].values():
assert "error" not in route
# 시종점 그대로 — 연도 경계가 정확히 맞물림
assert route["planned"][0] == list(seg["start"])
assert route["planned"][-1] == list(seg["end"])
assert results[0]["routes"]["gradeline"]["planned"][-1] == results[1]["start"]
rows = analyze.chain(results, {2026: "earthwork", 2027: None}, 1234.5)
assert [r["mode"] for r in rows] == ["earthwork", "gradeline"]
assert rows[0]["start_chainage_m"] == 1234.5
assert rows[1]["start_chainage_m"] == rows[0]["end_chainage_m"]
for result, row in zip(results, rows):
length = result["routes"][row["mode"]]["metrics"]["length_m"]
assert row["end_chainage_m"] - row["start_chainage_m"] == pytest.approx(length, abs=1e-3)
line = analyze.joined_line(results, rows)
first = results[0]["routes"]["earthwork"]["planned"]
second = results[1]["routes"]["gradeline"]["planned"]
assert len(line) == len(first) + len(second) - 1 # 경계점 한 번만(꺾인 채)
assert line[len(first) - 1] == list(SEGMENTS[0]["end"])
def test_same_input_reads_store(run, monkeypatch) -> None:
first = run()
assert [r["stored"] for r in first] == [False, False]
def boom(*args, **kwargs):
raise AssertionError("저장본이 있는데 다시 셈")
monkeypatch.setattr(analyze, "generate_initial_route", boom)
again = run()
assert [r["stored"] for r in again] == [True, True]
assert again[1]["routes"] == first[1]["routes"]
# 입력(복도 · 기준)이 바뀌면 다시 셈
with pytest.raises(AssertionError, match="다시 셈"):
run(corridor_m=120.0)
with pytest.raises(AssertionError, match="다시 셈"):
run(criteria={**CRITERIA, "max_grade": 0.12})
def test_corridor_argument_keeps_b_default(hill) -> None:
line = [SEGMENTS[0]["start"], SEGMENTS[0]["end"]]
base = SearchGrid(line, hill, [], stream_offset_m=10.0)
wide = SearchGrid(line, hill, [], stream_offset_m=10.0, corridor_m=180.0)
assert base.corridor_m == CORRIDOR_M == 100.0
assert wide.cells > base.cells
def test_criteria_from_project() -> None:
from B05_Profile.B05_Profile_Engine_Grade import (
legal_grade_limit_pct,
legal_plan_radius_min_m,
)
got = analyze.plan_criteria("work", 99) # 못 고르는 속도 → 종류 기본
assert got["design_speed"] == 20
assert got["max_grade"] == legal_grade_limit_pct("work", "special", False, 20) / 100.0
assert got["min_radius_m"] == legal_plan_radius_min_m(20, "special")
+127
View File
@@ -0,0 +1,127 @@
"""D 분석 API(PLAN 530-2) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래 거절.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q
"""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_json import atomic_write_json
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
P0, P1, P2 = (0.0, 0.0), (300.0, 400.0), (900.0, 400.0)
def _route(points: list, length: float) -> dict:
return {"planned": points, "nodes": [], "curves": [], "metrics": {"length_m": length}}
class _Cursor:
def __init__(self, log):
self.log = log
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, sql, args=None):
self.log.append((sql, args))
class _Connection(_Cursor):
async def begin(self):
pass
async def commit(self):
self.log.append(("COMMIT", None))
async def rollback(self):
pass
def cursor(self, *args):
return _Cursor(self.log)
class _Pool:
def __init__(self, log):
self.log = log
def acquire(self):
return _Connection(self.log)
@pytest.fixture
def client(tmp_path, monkeypatch):
segments = [
{"year": 2026, "start": P0, "end": P1, "epsg": 5186, "route_json": None},
{"year": 2027, "start": P1, "end": P2, "epsg": 5186, "route_json": '{"mode": "gentle"}'},
]
for seg, routes in (
(segments[0], {"gradeline": _route([[0, 0], [100, 200], [300, 400]], 530.0)}),
(
segments[1],
{
"gradeline": _route([[300, 400], [900, 400]], 600.0),
"gentle": _route([[300, 400], [600, 500], [900, 400]], 640.0),
"earthwork": {"error": "길 없음"},
},
),
):
atomic_write_json(
analyze.derived_dir(tmp_path) / f"route_{seg['year']}.json",
{
"year": seg["year"],
"start": list(seg["start"]),
"end": list(seg["end"]),
"fingerprint": "f",
"routes": routes,
},
)
project = {"road_type": "work", "design_speed_kph": None, "route_start_m": 1000.0}
async def inputs(project_id):
return tmp_path, project, segments
log: list = []
monkeypatch.setattr(router_mod, "_plan_inputs", inputs)
monkeypatch.setattr(router_mod, "get_db_pool", lambda: _Pool(log))
app = FastAPI()
app.include_router(router_mod.router)
test = TestClient(app)
test.log = log
return test
def test_get_chains_saved_choice(client) -> None:
body = client.get(f"/api/projects/{PID}/plan/analysis").json()
chain = body["chain"]
assert [r["mode"] for r in chain] == ["gradeline", "gentle"] # 저장값 · 없으면 첫 성공
assert chain[0]["start_chainage_m"] == 1000.0
assert chain[1]["start_chainage_m"] == chain[0]["end_chainage_m"] == 1530.0
assert chain[1]["end_chainage_m"] == 2170.0
assert body["line"] == [[0, 0], [100, 200], [300, 400], [600, 500], [900, 400]]
def test_put_saves_server_routes(client) -> None:
got = client.put(f"/api/projects/{PID}/plan/analysis", json={"choices": {"2027": "gradeline"}})
assert got.status_code == 200
assert got.json()["chain"][1]["end_chainage_m"] == 2130.0
updates = [args for sql, args in client.log if sql.startswith("UPDATE plan_route_years")]
saved = {args[3]: json.loads(args[0]) for args in updates}
assert saved[2027]["mode"] == "gradeline" and saved[2027]["planned"] == [[300, 400], [900, 400]]
assert saved[2026]["start_chainage_m"] == 1000.0 and updates[0][1] == "f"
assert client.log[-1][0] == "COMMIT"
def test_put_rejects_unknown_or_empty_mode(client) -> None:
url = f"/api/projects/{PID}/plan/analysis"
assert client.put(url, json={"choices": {"2027": "follow"}}).status_code == 400
assert client.put(url, json={"choices": {"2027": "earthwork"}}).status_code == 409
assert not [sql for sql, _ in client.log if sql.startswith("UPDATE")]