diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts index fa66bf777..ce94a8e73 100644 --- a/E06_StabRock/E06_StabRock_UI_Page.ts +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -1,11 +1,11 @@ /* ============================================================================= * E06_StabRock_UI_Page.ts * E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow). - * 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석] + * 좌 = 절리군 표 · 해석 설정 · [초기화] [해석] * 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도) * 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표 - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset · - * [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET. + * 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset · + * [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음. * ========================================================================== */ import "./E06_StabRock_UI_Style.css"; @@ -88,11 +88,6 @@ export function renderE06StabRock(root: HTMLElement): void { // ---- 좌 ---- const cards = el("div", { className: "e06-cards" }); const status = el("div", { className: "e06-status" }); - const saveButton = createButton({ - label: t("Common_Btn_Save"), - variant: "ghost", - onClick: () => void save(), - }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", @@ -103,9 +98,9 @@ export function renderE06StabRock(root: HTMLElement): void { variant: "filled", onClick: () => void analyze(), }); - saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; + resetButton.disabled = analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(resetButton, saveButton, analyzeButton); + actions.append(resetButton, analyzeButton); panel.append(note(t("E06_Note")), cards, status, actions); attachCollapsible(cards); @@ -274,7 +269,7 @@ export function renderE06StabRock(root: HTMLElement): void { if (!cached) sessionStorage.removeItem(cacheKey(projectId)); view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints; dirty = Boolean(cached); - saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable; + resetButton.disabled = analyzeButton.disabled = !view.editable; renderCards(); }; @@ -292,20 +287,6 @@ export function renderE06StabRock(root: HTMLElement): void { return true; } - async function save(): Promise { - showLoadingOverlay(); - try { - if (await putJoints()) { - showToast(t("E06_Saved"), "success"); - await loadResult(); - } - } catch (error) { - showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); - } finally { - hideLoadingOverlay(); - } - } - async function reset(): Promise { if (!view?.editable) return; const ok = await showConfirmDialog( diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index 159cf7d51..9ab353736 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -1,7 +1,7 @@ """E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2). -- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면 - `cached` · force = 다시 +- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문(복사본 · 설정 · E02 물성 · + 엔진 파일 해시)이 저장본과 같으면 `cached` · force = 다시(시험 · 관리용) - `GET …/stab/area/progress` — 진행률 - `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약) @@ -26,6 +26,7 @@ import logging import time from collections.abc import Callable from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -46,7 +47,8 @@ from E07_StabArea import E07_StabArea_Engine as engine logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"]) -ENGINE_VERSION = "e07-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_FILES = ("E07_StabArea_Engine.py", "E07_StabArea_Router.py") # 엔진 판 = 이 파일들 해시 +MATERIAL_KEYS = ("gamma_t", "gamma_sat", "c", "phi") RESULT = Path("E07_StabArea") / "_derived" / "area.json" DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) RANGE_C_KPA, RANGE_PHI_DEG = ( @@ -122,8 +124,33 @@ def defaults(project_root: Path) -> dict[str, Any]: } -def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str: - body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION} +@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 ground_material(project_root: Path, material_id: str | None) -> dict[str, Any] | None: + """E02 물성 값(고른 id · 없으면 붕적토) — 지문에 넣어 E02 가 바뀌면 옛 결과가 됨.""" + materials = _ground(project_root).get("materials") or [] + mat = next((m for m in materials if m.get("id") == material_id), None) or next( + (m for m in materials if m.get("id") == DEFAULT_MATERIAL), None + ) + return {k: mat.get(k) for k in ("id", *MATERIAL_KEYS)} if mat else None + + +def fingerprint( + source_fingerprint: str, settings: dict[str, Any], ground: dict[str, Any] | None = None +) -> str: + body = { + "source": source_fingerprint, + "settings": settings, + "ground": ground, + "engine": engine_hash(), + } text = json.dumps(body, ensure_ascii=False, sort_keys=True) return hashlib.sha1(text.encode("utf-8")).hexdigest() @@ -216,7 +243,7 @@ def run( } doc = { "fingerprint": finger, - "engine": ENGINE_VERSION, + "engine": engine_hash(), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(time.perf_counter() - started, 2), @@ -275,7 +302,11 @@ async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]: project_root = await _project_root(project_id) root = _source_root(project_root) settings = payload.model_dump(exclude={"force"}) - finger = fingerprint(source.manifest(root)["source_fingerprint"], settings) + finger = fingerprint( + source.manifest(root)["source_fingerprint"], + settings, + ground_material(project_root, settings["material_id"]), + ) saved = await asyncio.to_thread(load, project_root) if not payload.force and saved and saved["fingerprint"] == finger: return {"status": "cached", "summary": saved["summary"]} @@ -311,7 +342,11 @@ async def progress(project_id: UUID) -> dict[str, Any]: def _overview(project_root: Path, root: Path) -> dict[str, Any]: saved = load(project_root) if saved: - current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"]) + current = fingerprint( + source.manifest(root)["source_fingerprint"], + saved["settings"], + ground_material(project_root, saved["settings"].get("material_id")), + ) saved["stale"] = saved["fingerprint"] != current return { "defaults": defaults(project_root), diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index 2cee76c17..32ae7397e 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -4,7 +4,8 @@ * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) - * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. + * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage + * · 없으면 지난 결과 조건 + 물성은 지금 E02 값. * ========================================================================== */ import "./E07_StabArea_UI_Style.css"; @@ -239,15 +240,10 @@ export function renderE07StabArea(root: HTMLElement): void { const analyzeButton = createButton({ label: t("E07_Analyze"), variant: "filled", - onClick: () => void analyze(false), - }); - const reanalyzeButton = createButton({ - label: t("E07_Reanalyze"), - variant: "ghost", - onClick: () => void analyze(true), + onClick: () => void analyze(), }); const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(defaultsButton, reanalyzeButton, analyzeButton); + actions.append(defaultsButton, analyzeButton); const readOnly = note(t("E07_ReadOnly"), "e07-hint"); readOnly.hidden = true; panel.append( @@ -295,24 +291,31 @@ export function renderE07StabArea(root: HTMLElement): void { remember(); }); fields.get("band_m")!.input.addEventListener("input", paintScenario); - material.select.addEventListener("change", () => { - const m = overview?.materials.find((x) => x.id === material.select.value); - if (!m) return; - const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v)); + /** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */ + function materialValues(id: unknown): Settings { + const m = overview?.materials.find((x) => x.id === id); + if (!m) return {}; const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); - set("gamma_t", m.gamma_t); - set("gamma_sat", m.gamma_sat); - set("c", m.c); - set("c_hi", m.c + rangeC); - set("phi", m.phi); - set("phi_hi", m.phi + rangePhi); + return { + gamma_t: m.gamma_t, + gamma_sat: m.gamma_sat, + c: m.c, + c_hi: m.c + rangeC, + phi: m.phi, + phi_hi: m.phi + rangePhi, + }; + } + + material.select.addEventListener("change", () => { + const values = materialValues(material.select.value); + for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v); remember(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); - analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } @@ -445,11 +448,11 @@ export function renderE07StabArea(root: HTMLElement): void { } } - async function analyze(force: boolean): Promise { + async function analyze(): Promise { try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { method: "POST", - body: JSON.stringify({ ...read(), force }), + body: JSON.stringify(read()), }); if (body.status === "cached") { showToast(t("E07_Cached"), "info"); @@ -478,8 +481,12 @@ export function renderE07StabArea(root: HTMLElement): void { const cached = JSON.parse( sessionStorage.getItem(cacheKey(projectId)) ?? "null", ) as Settings | null; - // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 - write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) }); + // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값 + const last = ov.result?.settings as Settings | undefined; + write({ + ...ov.defaults, + ...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }), + }); map.setRoute(ov.line, ov.stations); const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); if (p?.running) void poll(); diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index 6bfe08965..d35050163 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -266,3 +266,15 @@ def test_길_띠_측점표_저장본(project): "s", {**settings, "c": 6} ) json.dumps(doc) # 그대로 JSON + + +def test_지문_E02_물성_엔진_해시(project, monkeypatch): + """광역 물성 = E02 값 — E02 붕적토가 바뀌거나 엔진 파일이 바뀌면 지문이 달라 옛 결과.""" + settings = router_mod.defaults(project) + ground = router_mod.ground_material(project, settings["material_id"]) + assert ground["id"] == "colluvium" and ground["c"] == settings["c"] + assert router_mod.ground_material(project, "없는것") == ground # 모르는 id = 붕적토 + before = router_mod.fingerprint("s", settings, ground) + assert before != router_mod.fingerprint("s", settings, {**ground, "c": ground["c"] + 1}) + monkeypatch.setattr(router_mod, "engine_hash", lambda: "바뀜") + assert before != router_mod.fingerprint("s", settings, ground) diff --git a/ui_template/ui_template_locale_e06.ts b/ui_template/ui_template_locale_e06.ts index 83273a815..8888b1d95 100644 --- a/ui_template/ui_template_locale_e06.ts +++ b/ui_template/ui_template_locale_e06.ts @@ -10,8 +10,8 @@ import type { LocaleEntry } from "./ui_template_locale_common"; export const ui_locales_e06 = { /* --- E06 좌측 --- */ E06_Note: [ - "암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.", - "Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).", + "암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [해석](넣은 값 저장 + 해석). 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.", + "Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, then [Analyze] (saves and analyzes). Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).", ], E06_Card_Joints: ["절리군", "Joint sets"], E06_JointsHint: [ @@ -61,8 +61,10 @@ export const ui_locales_e06 = { E06_Running: ["해석 중…", "Analyzing…"], E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."], E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."], - E06_Saved: ["저장했습니다.", "Saved."], - E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E06_Unsaved: [ + "저장 안 됨(해석 전) — [해석]을 누르면 저장됩니다.", + "Not saved (before analysis) — [Analyze] saves.", + ], E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E06_Invalid: ["틀린 칸", "Invalid fields"], E06_Err_Range: ["범위 밖", "Out of range"], diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts index a405ebc12..577d387d2 100644 --- a/ui_template/ui_template_locale_e07.ts +++ b/ui_template/ui_template_locale_e07.ts @@ -44,7 +44,6 @@ export const ui_locales_e07 = { E07_LevelHi: ["측점 높음 문턱 (SI<1 비율 %)", "Station high threshold (SI<1 share %)"], E07_Defaults: ["기본값", "Defaults"], E07_Analyze: ["해석", "Analyze"], - E07_Reanalyze: ["다시 해석", "Re-analyze"], E07_Running: ["해석 중", "Analyzing"], E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."], E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."], @@ -67,8 +66,8 @@ export const ui_locales_e07 = { E07_Result_Title: ["광역 결과", "Area result"], E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."], E07_Result_Stale: [ - "엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].", - "The engine changed — re-analyze.", + "입력(E02 물성 · 엔진)이 바뀌어 결과가 옛것입니다 — [해석]을 누르세요.", + "Inputs (E02 soil · engine) changed — press [Analyze].", ], E07_Result_Note: [ "문헌 추정치 · 현장 시험 없음 · 기본값은 바꿀 수 있음. 표준 = 포화비 W = min(R·a / (T·sinθ), 1). SI = c · φ · T/R 범위 안에서 Fs > 1 일 확률(하한 Fs ≥ 1 이면 그 값 · 상한 Fs < 1 이면 0). Fs = 가장 나쁜 쪽(c · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",