feat(E06·E07): [해석] = 저장 · E06 [저장] · E07 [다시 해석] 뺌 · E07 엔진 판 해시 · 광역 물성 = E02 (PLAN 564-4)

- E06 — [저장] 단추 뺌 · [해석]이 고친 절리를 먼저 저장 · 안내 글 「[해석] = 저장 + 해석」
- E07 — [다시 해석] 뺌 · 엔진 판 = 엔진 · 길 파일 해시(engine_hash) · 지문에 E02 물성(ground_material)
  → E02 붕적토가 바뀌면 옛 결과 · 캐시 없이 열면 지난 조건 + 지금 E02 물성 값
- 시험 536_2 지문(E02 물성 · 엔진 해시) 함수 하나 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:03:03 +09:00
co-authored by Claude Opus 5.5
parent 205bc275cd
commit 95e352205d
6 changed files with 99 additions and 63 deletions
+6 -25
View File
@@ -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<void> {
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<void> {
if (!view?.editable) return;
const ok = await showConfirmDialog(
+43 -8
View File
@@ -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),
+30 -23
View File
@@ -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<void> {
async function analyze(): Promise<void> {
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<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (p?.running) void poll();
+12
View File
@@ -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)
+6 -4
View File
@@ -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"],
+2 -3
View File
@@ -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 · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",