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:
2026-10-26 13:51:48 +09:00
co-authored by Claude Opus 5.5
parent bef30eda25
commit 4753d88983
3 changed files with 106 additions and 12 deletions
+52 -11
View File
@@ -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}
+6 -1
View File
@@ -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