fix(D03): 끈 지점 분석이 저장 지점 분석본을 덮지 않음 — 파생 저장본 연도 + 지점 열쇠 파일 (PLAN 543-4)
- 분석본 = route_<연도>_<지점 열쇠>.json · GET 은 DB 지점 분석본 · 끈 지점 GET 은 그 분석 - [저장] 뒤 정본 = 끈 지점 분석본(DB 지점 따라감) - 쌓임 거르기 = 연도마다 최근 3 개 + 저장 지점 것 - 옛 꼴 route_<연도>.json 은 읽기만(공유 storage · 임시 배포 옛 판 서버가 읽음 — 옮기지 않음) - 시험 test_530_2 끈 지점 분석 · 저장 · 쌓임 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
- 추천 기준 = 프로젝트 임도 종류 · 설계속도 → B 법정 함수 · 지형 구분은 B 폴백(`TERRAIN_TYPE`)
|
||||
- 계류 이격 = 도엽 하천선(B 로더 그대로) · 복도 반폭 = `PLAN_CORRIDOR_M`(D 만)
|
||||
- 연도 경계는 꺾인 채(경계점 = 측점) · 누가거리 = 등록 시작 누가부터 고른 갈래 길이로 이어 셈
|
||||
- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>.json`(입력 지문 같으면 다시 안 셈)
|
||||
- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>_<지점 열쇠>.json`(입력 지문 같으면 다시
|
||||
안 셈) · 끈 지점 분석이 저장 지점 분석본을 덮지 않음 · 연도마다 최근 `KEEP_PER_YEAR` 개 + 저장
|
||||
지점 것만 남김 · 옛 꼴 `route_<연도>.json` 은 읽기만(공유 storage — 옛 판 서버가 아직 읽음)
|
||||
|
||||
계산 자리 = ③ 서버 단독(B 추천과 같음).
|
||||
|
||||
@@ -57,6 +59,8 @@ ALGO = 1
|
||||
# 계류 거르기 칸 · 지형 상자 여유(m) — `near_streams`
|
||||
STREAM_TILE_M = 200.0
|
||||
STREAM_PAD_M = 100.0
|
||||
# 연도마다 남길 분석본 수 — 저장 지점 분석본은 따로 늘 남김
|
||||
KEEP_PER_YEAR = 3
|
||||
|
||||
Progress = Callable[[int, str], None]
|
||||
Loader = Callable[..., tuple[Any, list]]
|
||||
@@ -86,17 +90,53 @@ def _same_point(a: Any, b: Any) -> bool:
|
||||
return abs(float(a[0]) - float(b[0])) < 1e-3 and abs(float(a[1]) - float(b[1])) < 1e-3
|
||||
|
||||
|
||||
def segment_path(project_root: Path, seg: dict[str, Any]) -> Path:
|
||||
"""연도 · 지점 열쇠 파일 — 지점이 다르면 다른 파일(덮지 않음)."""
|
||||
key = hashlib.blake2b(
|
||||
repr((_point(seg["start"]), _point(seg["end"]))).encode(), digest_size=4
|
||||
).hexdigest()
|
||||
return derived_dir(project_root) / f"route_{int(seg['year'])}_{key}.json"
|
||||
|
||||
|
||||
def _find(project_root: Path, seg: dict[str, Any]) -> tuple[Path, dict[str, Any]] | None:
|
||||
"""지점이 지금과 같은 저장본(1 mm) — 열쇠 파일 · 같은 연도 다른 열쇠(새것부터) · 옛 꼴 차례."""
|
||||
year = int(seg["year"])
|
||||
folder = derived_dir(project_root)
|
||||
exact = segment_path(project_root, seg)
|
||||
others = sorted(
|
||||
(p for p in folder.glob(f"route_{year}_*.json") if p != exact),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in (exact, *others, folder / f"route_{year}.json"):
|
||||
try:
|
||||
stored = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]):
|
||||
return path, stored
|
||||
return None
|
||||
|
||||
|
||||
def read_segment(project_root: Path, seg: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""저장본 — 지점이 지금과 같을 때만(입력 지문은 분석 때 다시 봄)."""
|
||||
try:
|
||||
stored = json.loads(
|
||||
(derived_dir(project_root) / f"route_{int(seg['year'])}.json").read_text("utf-8")
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]):
|
||||
return stored
|
||||
return None
|
||||
found = _find(project_root, seg)
|
||||
return found[1] if found else None
|
||||
|
||||
|
||||
def _prune(project_root: Path, seg: dict[str, Any]) -> None:
|
||||
"""연도 분석본 — 최근 `KEEP_PER_YEAR` 개 + 저장 지점(`saved` · 없으면 seg) 것만 남김 · 옛 꼴은
|
||||
안 지움."""
|
||||
start, end = seg.get("saved") or (seg["start"], seg["end"])
|
||||
canon = _find(project_root, {"year": seg["year"], "start": start, "end": end})
|
||||
files = sorted(
|
||||
derived_dir(project_root).glob(f"route_{int(seg['year'])}_*.json"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in files[KEEP_PER_YEAR:]:
|
||||
if canon is None or path != canon[0]:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _route_view(result: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -175,7 +215,8 @@ def analyze_segment(
|
||||
},
|
||||
"routes": routes,
|
||||
}
|
||||
atomic_write_json(derived_dir(project_root) / f"route_{int(seg['year'])}.json", result)
|
||||
atomic_write_json(segment_path(project_root, seg), result)
|
||||
_prune(project_root, seg)
|
||||
return {**result, "stored": False}
|
||||
|
||||
|
||||
|
||||
@@ -79,7 +79,12 @@ def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[d
|
||||
if set(by_year) != {seg["year"] for seg in segments}:
|
||||
raise HTTPException(status_code=400, detail="끈 지점의 연도가 사업연도와 다릅니다.")
|
||||
out = [
|
||||
{**seg, "start": tuple(by_year[seg["year"]].start), "end": tuple(by_year[seg["year"]].end)}
|
||||
{
|
||||
**seg,
|
||||
"start": tuple(by_year[seg["year"]].start),
|
||||
"end": tuple(by_year[seg["year"]].end),
|
||||
"saved": (seg["start"], seg["end"]), # 저장 지점 — 그 분석본은 쌓임 거르기에서 남김
|
||||
}
|
||||
for seg in segments
|
||||
]
|
||||
for prev, seg in zip(out, out[1:]):
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
@@ -253,3 +254,50 @@ def test_put_edit_rejects(client) -> None:
|
||||
good = _edit([[300, 400], [600, 560], [900, 400]])
|
||||
assert client.put(url, json={"choices": {}, "edits": {"2027": good}}).status_code == 403
|
||||
assert not [sql for sql, _ in client.log if sql.startswith("UPDATE")]
|
||||
|
||||
|
||||
def _analyze_moved(client, tmp_path, monkeypatch, p1, length=700.0) -> None:
|
||||
"""끈 지점 분석(`POST …/plan/analyze` 와 같은 길) — 엔진 · 지문은 가짜."""
|
||||
monkeypatch.setattr(analyze, "segment_fingerprint", lambda seg, *a, **k: "g")
|
||||
monkeypatch.setattr(
|
||||
analyze,
|
||||
"generate_initial_route",
|
||||
lambda expected, *a, mode, **k: _route([list(expected[0]), list(expected[1])], length),
|
||||
)
|
||||
points = [router_mod.PlanPoint(**p) for p in _points(p1)]
|
||||
segs = router_mod._override(client.segments, points)
|
||||
analyze.analyze_plan(tmp_path, segs, analyze.plan_criteria("work", None), workers=1)
|
||||
|
||||
|
||||
def test_moved_analysis_keeps_saved_store(client, tmp_path, monkeypatch) -> None:
|
||||
"""543-4 — 끈 지점 분석은 지점 열쇠 파일로 · 저장 지점 GET 그대로 · 끈 지점 GET 은 그 분석 ·
|
||||
[저장] 뒤 정본 = 끈 지점 분석본 · 옛 꼴 파일은 읽기만 · 쌓임 거르기는 저장 지점 것을 남김."""
|
||||
url = f"/api/projects/{PID}/plan/analysis"
|
||||
before = client.get(url).json()
|
||||
moved = (310.0, 400.0)
|
||||
_analyze_moved(client, tmp_path, monkeypatch, moved)
|
||||
folder = analyze.derived_dir(tmp_path)
|
||||
assert (folder / "route_2026.json").is_file() and (folder / "route_2027.json").is_file()
|
||||
|
||||
assert client.get(url).json() == before # 저장 지점 = 옛 분석 그대로
|
||||
view = client.get(url, params={"points": json.dumps(_points(moved))}).json()
|
||||
assert [y["analysis"]["fingerprint"] for y in view["years"]] == ["g", "g"]
|
||||
assert view["chain"][1]["end_chainage_m"] == 2400.0
|
||||
|
||||
# [저장] = 끈 지점 정본 → 저장 지점 GET 이 그 분석
|
||||
body = {"choices": {"2027": "gentle"}, "points": _points(moved)}
|
||||
assert client.put(url, json=body).status_code == 200
|
||||
for seg, (start, end) in zip(client.segments, ((P0, moved), (moved, P2))):
|
||||
seg.update(start=start, end=end)
|
||||
after = client.get(url).json()
|
||||
assert [y["analysis"]["fingerprint"] for y in after["years"]] == ["g", "g"]
|
||||
|
||||
# 쌓임 — 저장 지점 것은 가장 옛것이어도 남음 · 연도마다 최근 KEEP_PER_YEAR 개
|
||||
for path in folder.glob("route_*_*.json"):
|
||||
os.utime(path, (1000.0, 1000.0))
|
||||
for n in range(analyze.KEEP_PER_YEAR + 1):
|
||||
_analyze_moved(client, tmp_path, monkeypatch, (320.0 + n, 400.0))
|
||||
keyed = sorted(p.name for p in folder.glob("route_2026_*.json"))
|
||||
assert len(keyed) == analyze.KEEP_PER_YEAR + 1
|
||||
assert analyze.segment_path(tmp_path, client.segments[0]).name in keyed
|
||||
assert client.get(url).json() == after
|
||||
|
||||
Reference in New Issue
Block a user