- B04 Router_Watershed — 분석 응답 · 단계 산출물 저장을 Output.write_analysis 로 뗌(primary-region 같은 길) · 도엽 좌표 저장본 메모리 보관(2 벌) - B04 Router_Watershed_Live — 저장 노선 기준(초안 없음 · 지문 같음) 답이면 마지막 끊김 확인 뒤 분석을 남김 · design/save 잠금 중이면 안 남김 · 남길 때 같은 잠금 쥠 · POST /drainage/live/cancel(그 차례가 마지막일 때만 지움) · 셈 멈춤 로그 - B05 Drainage_Live — 요청 끊을 때 서버에 끊김 알림 · 저장 노선 [업데이트] 답이 저장분 낡음 걷음 · 되돌리기로 초안이 없어지면 저장분의 마지막 낡음으로 표시 바로 - 시험 test_349_drainage_live_flow 고침 — 되돌리기 직후 낡음 기대가 옛 동작(답 올 때까지 낡음) · 새 동작 = 저장분 마지막 낡음(여기선 꺼짐) + 끊김 알림 확인 - 시험 test_347_2 — 「작업본에 안 씀」 옛 설명 고침 · 남김 · 초안 안 남김 · 잠금 중 안 남김 · 끊김 알림 시험 더함 · test_347_1 메모리 보관 확인 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
9.0 KiB
Python
232 lines
9.0 KiB
Python
"""347-2 「지금 노선으로 셈」 서버 길 · 347-3 관 자리 후보.
|
|
|
|
- 늦은 차례: 계산 중 새 차례가 오면 단계 사이에서 그만둠(superseded) · 그만둔 셈은 안 남김.
|
|
- 저장 노선(초안 없음 · 지문 같음) 답만 분석을 남김(520-2) · 초안 노선 · design/save 잠금 중엔 안 남김.
|
|
- 끊김 알림 = 그 차례가 아직 마지막일 때만 지움(새 차례는 안 끊음).
|
|
- 메모리 격자로 세부 유역(파일 안 읽음) · 노선 초안 → 그린 노선.
|
|
- 관 자리 후보: 최대 간격(100 m)을 넘는 구간에만 · 기존 관 자리엔 안 냄 · 관 목록엔 안 넣음.
|
|
- [확정](recompute) = 낡았으면 배수유역부터 다시 셈 · [저장] = 안 셈.
|
|
"""
|
|
|
|
import asyncio
|
|
import math
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from uuid import uuid4
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from shapely.geometry import LineString
|
|
|
|
from B04_PreProcess import B04_PreProcess_Engine_Watershed_Expand as expand
|
|
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
|
|
from B04_PreProcess import B04_PreProcess_Router_Watershed_Live as live
|
|
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
|
|
from common_util.common_util_drainage_context import DrainageContext
|
|
from common_util.common_util_drainage_detail import (
|
|
RoadRouting,
|
|
build_detail,
|
|
pipes_from_chainages,
|
|
suggest_pipe_positions,
|
|
)
|
|
from common_util.common_util_route_geometry import RouteVertex
|
|
|
|
VERTICES = [RouteVertex(0.0, 0.0, 100.0, 0.0), RouteVertex(300.0, 0.0, 90.0, 300.0)]
|
|
|
|
|
|
def test_draft_vertices_follow_nodes():
|
|
draft = {"nodes": [[0, 0], [100, 0], [100, 100]], "curveOn": [False, False, False]}
|
|
vertices = live.draft_vertices(draft, 30.0)
|
|
assert vertices[0].x == 0 and vertices[-1].y == 100
|
|
assert math.isclose(vertices[-1].chainage_m, 200.0)
|
|
|
|
|
|
def test_candidates_only_in_long_gaps():
|
|
strength = np.zeros(301)
|
|
strength[150] = 50.0 # 흐름이 모이는 자리
|
|
pipes = pipes_from_chainages(VERTICES, [0.0, 300.0])
|
|
picked = suggest_pipe_positions(VERTICES, pipes, strength)
|
|
assert picked and all(0 < pipe.chainage_m < 300 for pipe in picked)
|
|
assert all(pipe.chainage_m == round(pipe.chainage_m) for pipe in picked) # 정수 m
|
|
dense = pipes_from_chainages(VERTICES, [0.0, 90.0, 180.0, 270.0, 300.0])
|
|
assert suggest_pipe_positions(VERTICES, dense, strength) == []
|
|
|
|
|
|
def _routing() -> RoadRouting:
|
|
spec = GridSpec(x_min=-5.0, y_max=5.0, cell_m=1.0, n_rows=10, n_cols=310)
|
|
road_index = np.arange(5 * 310 + 5, 5 * 310 + 305, dtype=np.int32)
|
|
road_slot = np.full(spec.size, -1, dtype=np.int32)
|
|
road_slot[road_index] = np.arange(road_index.size)
|
|
return RoadRouting(
|
|
spec=spec,
|
|
road_slot=road_slot,
|
|
path_length=np.zeros(spec.size, np.float32),
|
|
elevation=np.full(spec.size, 95.0, np.float32),
|
|
road_cell_index=road_index,
|
|
road_chainage=np.arange(road_index.size, dtype=np.float64),
|
|
strength=np.ones(road_index.size, np.int64),
|
|
)
|
|
|
|
|
|
def test_build_detail_uses_memory_routing(tmp_path):
|
|
missing = tmp_path / "없는 폴더" # 파일을 읽으면 None 이 됨
|
|
detail = build_detail(missing, VERTICES, [0.0, 300.0], _routing(), [])
|
|
assert detail is not None and detail.pipe_candidates
|
|
assert build_detail(missing, VERTICES, [0.0, 300.0]) is None
|
|
|
|
|
|
def _patch_live(monkeypatch, preview_fn):
|
|
async def prepare(project_id):
|
|
return {"contours": [], "streams": [], "contour_key": "k"}
|
|
|
|
async def load(project_id):
|
|
return DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES), ""
|
|
|
|
monkeypatch.setattr(live, "_prepare", prepare)
|
|
monkeypatch.setattr(live, "load_drainage_context", load)
|
|
monkeypatch.setattr(live, "drainage_dir", lambda stored: Path("."))
|
|
monkeypatch.setattr(live, "preview_stages", preview_fn)
|
|
calls = SimpleNamespace(build=0)
|
|
|
|
def build(*args):
|
|
calls.build += 1
|
|
return None, []
|
|
|
|
monkeypatch.setattr(live, "_build", build)
|
|
return calls
|
|
|
|
|
|
def test_newer_generation_stops_between_stages(monkeypatch):
|
|
pid = uuid4()
|
|
|
|
def preview(vertices, contours, streams, store, should_stop):
|
|
live._LATEST_GENERATION[str(pid)] = 8 # 계산 중 새 조작 → 새 요청이 차례를 올림
|
|
if should_stop():
|
|
raise expand.AnalysisStopped
|
|
|
|
calls = _patch_live(monkeypatch, preview)
|
|
result = asyncio.run(live.post_drainage_live(pid, {"generation": 7, "points": []}))
|
|
assert result == live.SUPERSEDED and calls.build == 0
|
|
|
|
|
|
def test_newer_generation_after_preview_skips_split(monkeypatch):
|
|
pid = uuid4()
|
|
|
|
def preview(vertices, contours, streams, store, should_stop):
|
|
assert not should_stop()
|
|
live._LATEST_GENERATION[str(pid)] = 3
|
|
return SimpleNamespace(routing=object())
|
|
|
|
calls = _patch_live(monkeypatch, preview)
|
|
result = asyncio.run(live.post_drainage_live(pid, {"generation": 2}))
|
|
assert result == live.SUPERSEDED and calls.build == 0
|
|
|
|
|
|
def test_engine_stops_in_expansion_round():
|
|
contours = [
|
|
{
|
|
"geometry": {
|
|
"type": "LineString",
|
|
"coordinates": [
|
|
[150 + r * math.cos(t), -150 + r * math.sin(t)]
|
|
for t in np.linspace(0, 2 * math.pi, 121)
|
|
],
|
|
},
|
|
"properties": {"elevation": 300 - r},
|
|
}
|
|
for r in range(10, 200, 10)
|
|
]
|
|
spec = GridSpec(x_min=100.0, y_max=-100.0, cell_m=1.0, n_rows=100, n_cols=100)
|
|
domain = np.zeros((100, 100), bool)
|
|
domain[20:80, 20:80] = True
|
|
route = LineString([(110.0, -150.0), (190.0, -150.0)])
|
|
with pytest.raises(expand.AnalysisStopped):
|
|
expand.expand_by_red_boundary(
|
|
spec, domain, contours, route, [], None, should_stop=lambda: True
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"recompute, stale, expected", [(True, True, 1), (True, False, 0), (False, True, 0)]
|
|
)
|
|
def test_confirm_recomputes_only_when_stale(monkeypatch, recompute, stale, expected):
|
|
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
|
|
|
|
calls = []
|
|
|
|
async def is_stale(project_id):
|
|
return stale
|
|
|
|
async def region(project_id, refresh=False):
|
|
calls.append(refresh)
|
|
return {"status": "success"}
|
|
|
|
async def resolve(*args, **kwargs):
|
|
return basins.JSONResponse(status_code=404, content={})
|
|
|
|
monkeypatch.setattr(basins, "_analysis_stale", is_stale)
|
|
monkeypatch.setattr(watershed, "get_primary_region", region)
|
|
monkeypatch.setattr(basins, "_resolve", resolve)
|
|
asyncio.run(basins.put_pipe_points(uuid4(), {"points": []}, None, recompute))
|
|
assert len(calls) == expected and all(calls)
|
|
|
|
|
|
def _patch_keep(monkeypatch):
|
|
preview = SimpleNamespace(
|
|
routing=object(), region=SimpleNamespace(split=SimpleNamespace(upstream=[]))
|
|
)
|
|
_patch_live(monkeypatch, lambda *args: preview)
|
|
monkeypatch.setattr(live, "_build", lambda *args: (object(), []))
|
|
monkeypatch.setattr(live, "live_routing", lambda *args: None)
|
|
monkeypatch.setattr(live, "_payload", lambda *args: {"status": "success"})
|
|
writes = []
|
|
monkeypatch.setattr(live, "write_analysis", lambda *args: writes.append(args[-1]))
|
|
return writes
|
|
|
|
|
|
def test_saved_route_answer_is_kept(monkeypatch):
|
|
writes = _patch_keep(monkeypatch)
|
|
result = asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": None}))
|
|
assert result["status"] == "success" and writes == [VERTICES]
|
|
|
|
|
|
def test_draft_route_answer_not_kept(monkeypatch):
|
|
from B05_Profile import B05_Profile_Router_Replan as replan
|
|
|
|
writes = _patch_keep(monkeypatch)
|
|
moved = [RouteVertex(0.0, 5.0, 100.0, 0.0), RouteVertex(300.0, 5.0, 90.0, 300.0)]
|
|
|
|
async def criteria(project_id):
|
|
return (30.0,)
|
|
|
|
monkeypatch.setattr(replan, "_plan_criteria", criteria)
|
|
monkeypatch.setattr(live, "draft_vertices", lambda draft, radius: moved)
|
|
monkeypatch.setattr(live, "_open_sampler", lambda *args: None)
|
|
monkeypatch.setattr(live, "resolve_route_profile", lambda drawn, sampler: (drawn, "x"))
|
|
draft = {"nodes": [[0, 5], [300, 5]]}
|
|
asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": draft}))
|
|
assert writes == []
|
|
monkeypatch.setattr(live, "draft_vertices", lambda draft, radius: VERTICES) # 지문 같음
|
|
asyncio.run(live.post_drainage_live(uuid4(), {"generation": 1, "route": draft}))
|
|
assert writes == [VERTICES]
|
|
|
|
|
|
def test_not_kept_while_design_save_holds_lock(monkeypatch):
|
|
writes = _patch_keep(monkeypatch)
|
|
pid = uuid4()
|
|
|
|
async def run():
|
|
async with live.guard.project_lock(str(pid)):
|
|
return await live.post_drainage_live(pid, {"generation": 1})
|
|
|
|
assert asyncio.run(run())["status"] == "success" and writes == []
|
|
|
|
|
|
def test_cancel_clears_only_latest_generation():
|
|
pid = uuid4()
|
|
live._LATEST_GENERATION[str(pid)] = 5
|
|
assert asyncio.run(live.post_drainage_live_cancel(pid, {"generation": 4}))["stopped"] is False
|
|
assert live._LATEST_GENERATION[str(pid)] == 5
|
|
assert asyncio.run(live.post_drainage_live_cancel(pid, {"generation": 5}))["stopped"] is True
|
|
assert str(pid) not in live._LATEST_GENERATION
|