feat(E04): 3D 결과 열쇠에 조건 · 옛 이름 읽기 · 엔진 판 = 엔진 파일 해시 (PLAN 564-2)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user