From 4753d88983176d5d1845cb95a34bda0648c4981b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 13:51:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(D03):=20=EB=81=88=20=EC=A7=80=EC=A0=90=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=EC=9D=B4=20=EC=A0=80=EC=9E=A5=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=EB=B6=84=EC=84=9D=EB=B3=B8=EC=9D=84=20=EB=8D=AE?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=8C=20=E2=80=94=20=ED=8C=8C=EC=83=9D?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EB=B3=B8=20=EC=97=B0=EB=8F=84=20+=20?= =?UTF-8?q?=EC=A7=80=EC=A0=90=20=EC=97=B4=EC=87=A0=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?(PLAN=20543-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분석본 = route_<연도>_<지점 열쇠>.json · GET 은 DB 지점 분석본 · 끈 지점 GET 은 그 분석 - [저장] 뒤 정본 = 끈 지점 분석본(DB 지점 따라감) - 쌓임 거르기 = 연도마다 최근 3 개 + 저장 지점 것 - 옛 꼴 route_<연도>.json 은 읽기만(공유 storage · 임시 배포 옛 판 서버가 읽음 — 옮기지 않음) - 시험 test_530_2 끈 지점 분석 · 저장 · 쌓임 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py | 63 ++++++++++++++++---- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 7 ++- resources/tester/test_530_2_plan_router.py | 48 +++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py index ea07d5d60..35d2509a6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py @@ -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} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 913968c1b..94fbe4563 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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:]): diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 05553c6d2..0ade27d52 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -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