From f747e41e289141a28f45ea70cd2ac069fad03cd6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 27 Oct 2026 19:57:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(E04):=203D=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=97=B4=EC=87=A0=EC=97=90=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20?= =?UTF-8?q?=EC=98=9B=20=EC=9D=B4=EB=A6=84=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=ED=8C=90=20=3D=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=95=B4=EC=8B=9C=20(PLAN=20564-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - section_key(start, end, condition) → 0.00_100.00_dry 꼴 · 건기 → 우기가 서로 덮지 않음 - 옛 시작_끝.json 은 그 안 조건 열쇠로 읽음(load · saved_summaries) - ENGINE_VERSION 대신 engine_hash()(엔진 파일 넷 해시) — 판 올리기 잊음 없음 - 시험 533_3 router(건기 → 우기 둘 다 남음 · 옛 이름 · 엔진 해시) · 534_3 견본 파일 새 이름 Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Router.py | 7 +- E04_Stab3D/E04_Stab3D_Run.py | 82 +++++++++++++++++------ resources/tester/test_533_3_e04_router.py | 43 ++++++++++-- resources/tester/test_534_3_e05_report.py | 2 +- 4 files changed, 105 insertions(+), 29 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 2416a95e8..374ba4eab 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -5,7 +5,7 @@ - `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시) - `GET …/stab/progress3d` — 진행률 - `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지) -- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면) +- `GET …/stab/result3d?key=` — 한 구간 · 조건 저장본(래스터 · 임계 활동면) · 열쇠 = `시작_끝_조건` 계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403). """ @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio import logging +import re from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -126,7 +127,7 @@ async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, An project_root = await _project_root(project_id) root = _source_root(project_root) settings = _settings(payload) - key = run.section_key(settings["start_m"], settings["end_m"]) + key = run.section_key(settings["start_m"], settings["end_m"], settings["condition"]) finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings) saved = await asyncio.to_thread(run.load, project_root, key) if not payload.force and saved and saved["fingerprint"] == finger: @@ -229,7 +230,7 @@ async def result3d( overview = await asyncio.to_thread(_overview, project_root, root) editable = await _editable(project_id, session) return {"status": "success", "editable": editable, **overview} - if not all(ch.isdigit() or ch in "._" for ch in key): + if not re.fullmatch(r"\d+\.\d{2}_\d+\.\d{2}(_(dry|wet))?", key): raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.") doc = await asyncio.to_thread(run.load, project_root, key) if doc is None: diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index fcd600263..2b40fcaaf 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -1,8 +1,9 @@ """E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3). 계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만. -저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 · -요청 · 엔진 판)이 같으면 다시 셈하지 않음. +저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 열쇠 = `시작_끝_조건` +(예 `0.00_100.00_wet` · 옛 `시작_끝` 파일은 그 안 조건으로 읽음) · 입력 지문(복사본 지문 · +E02 지반 · 요청 · 엔진 판 = 엔진 파일 해시)이 같으면 다시 셈하지 않음. 래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠): grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열 @@ -22,6 +23,7 @@ import math import time from collections.abc import Callable from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any @@ -34,7 +36,13 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine -ENGINE_VERSION = "e04-4" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_FILES = ( + "E04_Stab3D_Engine_Column.py", + "E04_Stab3D_Engine_Grid.py", + "E04_Stab3D_Engine_Search.py", + "E04_Stab3D_Run.py", +) +CONDITIONS = ("dry", "wet") STAGE_DIR = "E04_Stab3D" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리) @@ -48,8 +56,23 @@ def results_dir(project_root: Path) -> Path: return Path(project_root) / STAGE_DIR / "results" -def section_key(start_m: float, end_m: float) -> str: - return f"{start_m:.2f}_{end_m:.2f}" +@lru_cache(maxsize=1) +def engine_hash() -> str: + """엔진 판 = 엔진 코드 지문 — 코드가 바뀌면 지문이 달라져 다시 셈.""" + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def section_key(start_m: float, end_m: float, condition: str = "dry") -> str: + return f"{start_m:.2f}_{end_m:.2f}_{condition}" + + +def _doc_key(doc: dict[str, Any]) -> str: + summary = doc["summary"] + condition = summary.get("condition") or doc["settings"].get("condition") or "dry" + return section_key(float(summary["start_m"]), float(summary["end_m"]), condition) def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]: @@ -85,7 +108,7 @@ def fingerprint( "source": source_fingerprint, "ground": ground, "settings": settings, - "engine": ENGINE_VERSION, + "engine": engine_hash(), }, ensure_ascii=False, sort_keys=True, @@ -308,7 +331,7 @@ def pack( ) return { "fingerprint": finger, - "engine": ENGINE_VERSION, + "engine": engine_hash(), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(elapsed_s, 2), @@ -346,24 +369,37 @@ def save(project_root: Path, key: str, result: dict[str, Any]) -> None: def load(project_root: Path, key: str) -> dict[str, Any] | None: - path = results_dir(project_root) / f"{key}.json" - return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + """열쇠 파일 → 없으면 옛 이름(`시작_끝`) 파일이 같은 조건일 때.""" + folder = results_dir(project_root) + path = folder / f"{key}.json" + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + base, _, condition = key.rpartition("_") + legacy = folder / f"{base}.json" + if condition in CONDITIONS and legacy.is_file(): + doc = json.loads(legacy.read_text(encoding="utf-8")) + if _doc_key(doc) == key: + return doc + return None def saved_summaries(project_root: Path) -> list[dict[str, Any]]: - """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용.""" - out = [] + """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용. + + 열쇠 = 구간 + 조건 · 같은 열쇠면 새 이름 파일이 옛 이름 파일을 이김.""" + found: dict[str, dict[str, Any]] = {} for path in sorted(results_dir(project_root).glob("*.json")): doc = json.loads(path.read_text(encoding="utf-8")) - out.append( - { - "key": path.stem, - "fingerprint": doc["fingerprint"], - "settings": doc["settings"], - **doc["summary"], - } - ) - return out + key = _doc_key(doc) + if key in found and path.stem != key: + continue + found[key] = { + "key": key, + "fingerprint": doc["fingerprint"], + "settings": doc["settings"], + **doc["summary"], + } + return [found[k] for k in sorted(found)] def analyze( @@ -385,5 +421,9 @@ def analyze( ground = read_ground(project_root) raw = run_engine(root, around, ground, settings, on_progress, should_stop) result = pack(raw, picked, settings, finger, time.perf_counter() - started) - save(project_root, section_key(settings["start_m"], settings["end_m"]), result) + save( + project_root, + section_key(settings["start_m"], settings["end_m"], settings["condition"]), + result, + ) return result["summary"] diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 0e7793b5d..1b418129e 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -107,7 +107,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): monkeypatch.setattr(run, "run_engine", counted) first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() - assert first == {"status": "accepted", "key": "0.00_100.00"} + assert first == {"status": "accepted", "key": "0.00_100.00_dry"} done = _wait(client) assert done["error"] is None and done["percent"] == 100 assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng" @@ -133,7 +133,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): _wait(client) assert len(calls) == 3 - doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json() + doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"}).json() grid = doc["grid"] assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"] f = np.array([np.nan if v is None else v for v in doc["f"]]) @@ -143,7 +143,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): assert doc["stale"] is False over = client.get(f"/api/projects/{PID}/stab/result3d").json() - assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry"] and over["editable"] is True assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0 # E02 지반을 바꾸면 저장본이 「옛 것」 (project / "E02_StabGround").mkdir() @@ -179,7 +179,9 @@ def test_cancel_and_restart(client, monkeypatch): body = _wait(client) assert body["cancelled"] is True and body["error"] is None assert ( - client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code + client.get( + f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"} + ).status_code == 404 ) # 다시 시작 @@ -191,6 +193,39 @@ def test_cancel_and_restart(client, monkeypatch): assert body["cancelled"] is False and body["percent"] == 100 +def test_dry_then_wet_both_kept(client, project, monkeypatch): + """건기 → 우기 = 다른 열쇠 · 앞 결과가 덮이지 않음 · 옛 이름(조건 없음) 파일도 읽음.""" + monkeypatch.setattr(run, "run_engine", _fake_engine) + for condition in ("dry", "wet"): + body = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": condition} + ).json() + assert body == {"status": "accepted", "key": f"0.00_100.00_{condition}"} + _wait(client) + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [(s["key"], s["condition"]) for s in over["saved"]] == [ + ("0.00_100.00_dry", "dry"), + ("0.00_100.00_wet", "wet"), + ] + folder = run.results_dir(project) + # 옛 이름 — 우기 파일을 조건 없는 이름으로 옮기면 우기 열쇠로 읽힘 + (folder / "0.00_100.00_wet.json").rename(folder / "0.00_100.00.json") + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry", "0.00_100.00_wet"] + url = f"/api/projects/{PID}/stab/result3d" + assert client.get(url, params={"key": "0.00_100.00_wet"}).json()["stale"] is False + again = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": "wet"} + ).json() + assert again["status"] == "cached" + + +def test_engine_hash_in_fingerprint(monkeypatch): + before = run.fingerprint("a", None, {}) + monkeypatch.setattr(run, "engine_hash", lambda: "changed") + assert run.fingerprint("a", None, {}) != before + + def test_bad_requests(client): url = f"/api/projects/{PID}/stab/analyze3d" assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400 diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 41da1f402..f130b9a86 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -170,7 +170,7 @@ def project(tmp_path, monkeypatch) -> Path: path.write_text(json.dumps(ground), encoding="utf-8") r3 = root / "E04_Stab3D" / "results" r3.mkdir(parents=True) - (r3 / "0.00_200.00.json").write_text( + (r3 / "0.00_200.00_wet.json").write_text( json.dumps( { "fingerprint": "3d-1",