Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

- 충돌 D03_PlanAnalysis_Api.ts: 지형 받기 꼴 이름(TerrainCenter · TerrainAim) 같은 뜻 둘 → sub2(595 몫) TerrainAim 쪽 살림 · 쓰는 곳 없음
- PlanForestRoads bounds · distance_m 두 벌 → sub2 한 벌 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:49:10 +09:00
co-authored by Claude Opus 5.5
9 changed files with 564 additions and 128 deletions
+36 -3
View File
@@ -1,5 +1,6 @@
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음.
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 ·
받은 범위 좌표계 · 칸 상한 · 거리.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q
"""
@@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch):
monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge)
monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite)
monkeypatch.setattr(terrain, "build_sheet_surface_model", dem)
monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1
return tmp_path, seen
@@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None:
assert len(seen["merge"]) == merges and third["box_m"] == grown
def test_reset_drops_old_box_sheets_satellite(fake) -> None:
"""595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 ·
다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음."""
root, seen = fake
terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0))
old = set(seen["merge"][-1])
far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)]
got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0))
m = terrain.DEFAULT_MARGIN_M
assert got["box_m"] == pytest.approx(
[far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m]
)
assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음)
assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침
assert "cadastral" not in got
meta = terrain.received_meta(root)
assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0]
assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"]))
assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님
# 새로 아닌 이어 받기 = 가운데 기록 그대로
terrain.ensure_plan_terrain(root, far, EPSG)
assert terrain.received_meta(root)["center"] == [128.0, 36.0]
def test_cells_distance_and_limit(fake) -> None:
root, _ = fake
assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자
assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0
assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5
assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000
assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS
def test_extent_no_download_and_covered(fake) -> None:
root, seen = fake
before = terrain.plan_extent(root, [A, B], EPSG)
+159 -4
View File
@@ -1,5 +1,6 @@
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이
없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌.
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 ·
선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠
물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김.
옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) ·
노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`.
@@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
P0, P2 = (0.0, 0.0), (900.0, 400.0)
URL = f"/api/projects/{PID}/plan/terrain"
OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°)
Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1])
class _Pool:
@@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch):
test = TestClient(app)
test.plan = plan
test.info = info
test.root = tmp_path
return test
@@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
saved: list = []
works: list = []
def ensure(root, points, epsg, margin_m, report):
def ensure(root, points, epsg, margin_m, report, **kw):
calls.append((points, epsg, margin_m))
return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]}
async def save(project_id, models, epsg):
saved.append((models, epsg))
def start(project_id, kind, work):
def start(project_id, kind, work, root=None):
works.append(work)
return {"status": "accepted", "kind": kind}
@@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
# 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점
post({"points": []})
assert calls[-1] == ([P0, P2], 5186, None)
# ── 595 위치 바꿈 · 상한 · 끊김 ──
@pytest.fixture
def posting(client, monkeypatch):
"""POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록."""
calls: list = []
def ensure(root, points, epsg, margin_m, report, reset=False, center=None):
calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center})
return {"sheets": {"downloaded": []}, "models": []}
async def save(project_id, models, epsg):
return None
works: list = []
def start(project_id, kind, work, root=None):
works.append(work)
return {"status": "accepted", "kind": kind}
def post(body):
res = client.post(URL, json=body)
while works: # 일은 응답 뒤 돌림(앱 고리 밖)
asyncio.run(works.pop()(lambda percent, message: None))
return res
monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure)
monkeypatch.setattr(router_mod, "_save_models", save)
monkeypatch.setattr(router_mod, "_start_job", start)
client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}]
client.calls = calls
client.run_post = post
return client
def _have(client, box, epsg=5186, center=None):
meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg}
if center:
meta["center"] = list(center)
folder = router_mod.terrain.models_dir_of(client.root)
folder.mkdir(parents=True, exist_ok=True)
(folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8")
def test_center_near_adds_site_ring(posting) -> None:
"""가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침."""
from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km
res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json()
assert res["status"] == "accepted"
call = posting.calls[-1]
x, y = lonlat_to_tm(lon, lat, 5186)
r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M
assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r))
assert call["reset"] is False and call["center"] == (lon, lat)
# extent 에도 캐시 가운데 = 둘레가 상자에 들어감
got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json()
assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M)
def test_center_far_asks_then_reset(posting) -> None:
"""5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음)."""
from common_util.common_util_crs import tm_to_lonlat
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km
body = {"center": {"lon": lon, "lat": lat}}
far = posting.run_post(body).json()
assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2)
assert "km" in far["message"] and posting.calls == []
res = posting.run_post({**body, "reset": True}).json()
assert res["status"] == "accepted"
call = posting.calls[-1]
assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만
assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1)
def test_center_other_band_asks_then_moves(posting) -> None:
"""다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름."""
posting.plan["lines"] = [{"id": "a"}]
body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187)
ask = posting.run_post(body).json()
assert ask == {
"status": "band",
"epsg": 5186,
"center_epsg": 5187,
"message": ask["message"],
}
assert posting.calls == []
# 확인 전 extent = 가운데 무시(옛 띠 그대로)
assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186
posting.run_post({**body, "reset": True})
call = posting.calls[-1]
assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2
# 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레
(x0, y0), (x1, y1) = call["points"]
box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400]
_have(posting, box, epsg=5187, center=(129.1017, 36.8128))
got = posting.get(f"{URL}/extent").json()
assert got["epsg"] == 5187 and got["covered"] is True
def test_cells_limit_413(posting) -> None:
posting.plan["points"].append(
{"x": OX + 6000.0, "y": OY + 6000.0}
) # 6.7 × 6.7 km = 1,100 만 칸
res = posting.run_post({})
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
assert posting.calls == []
def test_progress_interrupted_after_restart(client, monkeypatch) -> None:
"""도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100."""
root = client.root
async def tile_root(project_id):
return root
monkeypatch.setattr(router_mod, "_tile_root", tile_root)
url = f"/api/projects/{PID}/plan/progress"
router_mod._JOBS.clear()
idle = client.get(url).json()["terrain"]
assert idle == {**router_mod.IDLE}
path = router_mod._job_path(root, "terrain")
seen = []
async def work(report):
report(65, "5/5 사업 지형면 만드는 중")
seen.append(json.loads(path.read_text("utf-8")))
async def run():
router_mod._start_job(PID, "terrain", work, root)
await asyncio.sleep(0.05)
asyncio.run(run())
assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록
assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록
path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐
router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔
got = client.get(url).json()["terrain"]
assert got["running"] is False and got["interrupted"] is True
assert got["percent"] == 65 and got["message"] is None and got["error"] is None
# 끝난 기록 = 끊김 아님
path.write_text(json.dumps({**seen[0], "running": False}), "utf-8")
assert client.get(url).json()["terrain"] == {**router_mod.IDLE}
+23 -3
View File
@@ -1,5 +1,5 @@
"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
"""
@@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1))
first = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists()
assert (
len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1
)
# 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음
again = FakeWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", again)
assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15
retry = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0
assert retry["count"] == 15 * 2 + 1
assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1
# 옛 열쇠(실패 조각 기록 없음) = 통째로
key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))
key.update(failed=1)
key.pop("failed_tiles")
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert again.calls == 1 + 15
# 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님)
key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])])
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99))
kept = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1
# ── 급경사 ──
@@ -1,5 +1,6 @@
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 ·
cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면
5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 +
거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의.
./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q
@@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
([("a.zip", b"not a zip")], "zip 파일을 열지"),
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"),
([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"),
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"),
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"),
]
for upload, text in bad:
with pytest.raises(ValueError, match=re.escape(text)):
@@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
def test_clip_box_site_5km_and_distance(tmp_path) -> None:
"""595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 ·
밖뿐 = 거절 + 거리."""
site = (231000.0, 450600.0)
frame = fr.clip_box(site, BOX)
assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음
assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪
assert fr.clip_box(site, None) == frame
got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site)
assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐
assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴
assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500
w, s, e, n = got["bounds"]
assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4)
assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4)
far = _shp_files(tmp_path / "far", [LINES[2]])
with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"):
fr.save_roads(tmp_path, far, frame, EPSG, site)
def test_no_upload_is_not_found(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
fr.road_layer(tmp_path, BOX, EPSG)