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