From 340e90d6cc17ded3b9f3dd034c616dd2d3a7e593 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:58:02 +0900 Subject: [PATCH 01/34] =?UTF-8?q?fix(534-3):=20E05=20=EA=B8=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=C2=B7=20=EA=B0=B1=EC=8B=A0=20=C2=B7=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EA=B0=80=20=EC=98=9B=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20=EB=B6=80=EB=A6=84=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EC=88=98=20=EA=BC=B4=20=EB=A7=9E=EC=B6=A4?= =?UTF-8?q?=20=C2=B7=20=ED=99=94=EB=A9=B4=20=EB=86=92=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길: asyncio.to_thread(build.save · refresh · reset) → store(나눈 storage 모듈) — PUT 500 고침 - 시트 서명: 수를 float 로 맞춤 — 화면(JS)을 거친 10.0 → 10 을 사용자 고침으로 잘못 알리던 덮임 알림 고침 - 화면: 본문 칸이 min-height 뿐이라 내장 엑셀 격자 높이 0 — 본문 높이 calc(100dvh - 머리) - 그림: 우기 · 지진 임계면 점선(건기와 겹쳐도 보임) · 해석 방법 글 짧게(Bishop) - 시험: 길(TestClient — 판 409 · 저장 · 갱신 · 그림 · xlsx · 초기화) · 서명 화면 왕복 같음 — 7 통과 Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Build.py | 15 +++++- E05_StabReport/E05_StabReport_Figure.py | 7 ++- E05_StabReport/E05_StabReport_Router.py | 6 +-- E05_StabReport/E05_StabReport_UI_Style.css | 2 +- resources/tester/test_534_3_e05_report.py | 55 ++++++++++++++++++++-- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index 6606c2a6c..d924938cf 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -48,7 +48,7 @@ LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N CASES = ("dry", "wet", "seismic") SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"} METHOD_NAMES = { - "bishop": "Bishop 간편법", + "bishop": "Bishop", "janbu": "Janbu 보정", "janbu_corrected": "Janbu 보정", "ordinary": "Fellenius", @@ -589,5 +589,16 @@ def sheet_sign(sheet: dict[str, Any]) -> str: "행": {k: v for k, v in (sheet.get("행") or {}).items() if v}, "열": {k: v for k, v in (sheet.get("열") or {}).items() if v}, } - text = json.dumps(body, ensure_ascii=False, sort_keys=True) + text = json.dumps(_numbers(body), ensure_ascii=False, sort_keys=True) return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def _numbers(value: Any) -> Any: + """수는 float 로 — 화면(JS)을 거치면 10.0 이 10 으로 옴(같은 값을 다르다고 보지 않게).""" + if isinstance(value, dict): + return {k: _numbers(v) for k, v in value.items()} + if isinstance(value, list): + return [_numbers(v) for v in value] + if isinstance(value, int | float) and not isinstance(value, bool): + return round(float(value), 9) + return value diff --git a/E05_StabReport/E05_StabReport_Figure.py b/E05_StabReport/E05_StabReport_Figure.py index 9f39a4a91..69e4f636c 100644 --- a/E05_StabReport/E05_StabReport_Figure.py +++ b/E05_StabReport/E05_StabReport_Figure.py @@ -21,6 +21,7 @@ BAND_W, BAND_H = 684, 320 OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222" CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"} CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SLIP_DASH = {"wet": "7 4", "seismic": "2 3"} # 건기 · 우기 임계면이 겹쳐도 둘 다 보이게 FONT = "font-family='Malgun Gothic, sans-serif'" @@ -155,7 +156,9 @@ def section_svg( # 임계 활동면 for slip in slips: color = CONDITION_COLORS.get(slip["condition"], NG) - out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2")) + dash = SLIP_DASH.get(slip["condition"]) + extra = {"stroke_dasharray": dash} if dash else {} + out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2", **extra)) # 치수 — 노폭 · 비탈 높이 if road: r_off, l_off = road @@ -206,7 +209,7 @@ def section_svg( ( CONDITION_COLORS.get(slip["condition"], NG), f"{name} 임계면 Fs {f:.2f}" if f else name, - "", + SLIP_DASH.get(slip["condition"], ""), ) ) if wet: diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py index 9132c1d7a..b27f334db 100644 --- a/E05_StabReport/E05_StabReport_Router.py +++ b/E05_StabReport/E05_StabReport_Router.py @@ -137,7 +137,7 @@ async def put_report(project_id: UUID, payload: SaveRequest) -> dict[str, Any]: status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요." ) try: - await asyncio.to_thread(build.save, root, payload.book) + await asyncio.to_thread(store.save, root, payload.book) except RecalcError as error: raise _recalc_errors(error) from error return {"status": "success", **await asyncio.to_thread(_state, root)} @@ -153,7 +153,7 @@ async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요." ) try: - result = await asyncio.to_thread(build.refresh, root) + result = await asyncio.to_thread(store.refresh, root) except build.MissingInput as error: raise HTTPException(status_code=409, detail=str(error)) from error except (RecalcError, PrintError) as error: @@ -169,7 +169,7 @@ async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any async def reset_report(project_id: UUID) -> dict[str, Any]: root, _ = await _project(project_id) async with _lock(project_id): - if not await asyncio.to_thread(build.reset, root): + if not await asyncio.to_thread(store.reset, root): raise HTTPException(status_code=404, detail="초기값이 없습니다.") return {"status": "success", **await asyncio.to_thread(_state, root)} diff --git a/E05_StabReport/E05_StabReport_UI_Style.css b/E05_StabReport/E05_StabReport_UI_Style.css index f958c575c..fda06c9da 100644 --- a/E05_StabReport/E05_StabReport_UI_Style.css +++ b/E05_StabReport/E05_StabReport_UI_Style.css @@ -6,7 +6,7 @@ .e05-page { display: flex; flex-direction: column; - height: 100%; + height: calc(100dvh - var(--app-header-h)); /* 본문 칸은 min-height 뿐 — 100% 가 안 풀림 */ min-height: 0; box-sizing: border-box; } diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index d46f0b3e5..e4dfc66f6 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1,7 +1,8 @@ -"""534-3(빌더 · storage) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store`. +"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. -가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 -측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · xlsx 되읽기 · PDF 쪽 수. +가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 · +복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · +덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. """ import io @@ -294,3 +295,51 @@ def test_단면_그림_SVG(): ) assert svg.startswith("= 4 + + +def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from E05_StabReport import E05_StabReport_Router as router_mod + + async def fake_project(_pid): + return project, 3 + + monkeypatch.setattr(router_mod, "_project", fake_project) + app = FastAPI() + app.include_router(router_mod.router) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3} + client = TestClient(app) + url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report" + try: + got = client.get(url).json() + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 6 + book = got["book"] + _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} + assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 + saved = client.put(url, json={"rev": got["rev"], "book": book}).json() + assert saved["rev"] != got["rev"] + assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409 + out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json() + assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청" + fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")}) + assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG" + assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400 + x = client.post(f"{url}/xlsx", json={"book": out["book"]}) + assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지" + back = client.post(f"{url}/reset").json() + assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {}) + + +def test_서명_화면_왕복_같음(project): + """JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음.""" + book = build.form() + build.build_program(book, build.gather(project)) + s3 = _sheets(book)["입력조건"] + assert s3["칸"]["B22"]["값"] == 10.0 + trip = json.loads(json.dumps(s3).replace("10.0", "10")) + assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3) From 54c8cff5622d6571f319c3119cfb40d1a2eb286c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:01:06 +0900 Subject: [PATCH 02/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20353=20=E2=80=94=20ORCA=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20=ED=83=AD=20=EB=B0=98=EB=B3=B5=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=91=90=20=EB=B2=88=EC=A7=B8=20=EC=A7=80=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index b8e7f2eae..b1976b7da 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -136,3 +136,8 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 ## 요구 352 (10-25 · 사용자) 너지금 오르카 브라우져 탭을 복수로 실행하고 있어. 재사용해. + +## 요구 353 (10-25 · 사용자) + +너 브라우져를 반복적으로 띄우고 있다고 +벌써 2번째야 From 15ce7cdd0b6cfaf8ee1eb316bde119f1bd7b78cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:01:30 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat(E04):=203D=20=ED=95=B4=EC=84=9D=20?= =?UTF-8?q?=3D=20=EC=8B=A4=EC=A0=9C=20=EC=97=94=EC=A7=84(533-1=20=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=A7=80=ED=98=95=20=C2=B7=20533-2=20=EA=B5=AC=20?= =?UTF-8?q?=C2=B7=20=ED=83=80=EC=9B=90=EC=B2=B4=20=ED=83=90=EC=83=89)=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=C2=B7=20=ED=83=90=EC=83=89=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=A5=A0=20=C2=B7=20=EB=81=8A=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B1=B4=EA=B8=B0/=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=EC=95=88=EC=A0=84=EC=9C=A8=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=C2=B7=20=EB=B0=A9=EB=B2=95=EB=B3=84=20F?= =?UTF-8?q?=20=C2=B7=20=EB=AA=BB=20=EB=9A=AB=EB=8A=94=20=EC=B8=B5=20?= =?UTF-8?q?=EC=9E=98=EB=A6=B0=20=ED=99=9C=EB=8F=99=EB=A9=B4=20+=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=9E=87=EA=B8=B0=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _Search 에 on_progress · should_stop(기본 None · 결과 불변 시험) — 533-2 시험 14 그대로 통과 - 탐색 노드 = 중심선 ± 탐색 폭 · 구간 누가 안 · 격자 = 그 밖 깊이 + 10 m 여유 - 미리보기(가짜) 엔진 지움 · 방법 고르기 → 조건(건기 · 우기) · Hovland 는 같은 임계면 비교 값 Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Engine_Search.py | 22 ++- E04_Stab3D/E04_Stab3D_Router.py | 8 +- E04_Stab3D/E04_Stab3D_Run.py | 205 ++++++++++++++++------ E04_Stab3D/E04_Stab3D_UI_Page.ts | 20 ++- E04_Stab3D/E04_Stab3D_UI_Settings.ts | 24 +-- E04_Stab3D/E04_Stab3D_UI_Style.css | 8 + E04_Stab3D/logic/E04_Stab3D_Mesh.ts | 7 +- E04_Stab3D/logic/E04_Stab3D_Raster.ts | 8 +- resources/tester/test_533_3_e04_engine.py | 180 +++++++++++++++++++ resources/tester/test_533_3_e04_logic.py | 10 ++ resources/tester/test_533_3_e04_router.py | 34 +++- ui_template/ui_template_locale_e04.ts | 19 +- 12 files changed, 444 insertions(+), 101 deletions(-) create mode 100644 resources/tester/test_533_3_e04_engine.py diff --git a/E04_Stab3D/E04_Stab3D_Engine_Search.py b/E04_Stab3D/E04_Stab3D_Engine_Search.py index 8a2c1426d..1716245f1 100644 --- a/E04_Stab3D/E04_Stab3D_Engine_Search.py +++ b/E04_Stab3D/E04_Stab3D_Engine_Search.py @@ -22,6 +22,7 @@ from __future__ import annotations import math import time +from collections.abc import Callable from dataclasses import dataclass, field import numpy as np @@ -61,6 +62,9 @@ class SearchParams: refine_below: float = 2.0 groundwater: dict | None = None seismic_kh: float = 0.0 + # 화면 고리(533-3) — 결과 불변 · on_progress(percent, message) · should_stop() 참이면 InterruptedError + on_progress: Callable[[int, str], None] | None = None + should_stop: Callable[[], bool] | None = None @dataclass @@ -264,6 +268,13 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: best = {"governing": np.inf} seen: set[int] = set() counters = {"trials": 0, "evaluated": 0} + stage = [0, 40, "거친 탐색"] # 진행률 — 시작 % · 폭 % · 이름 + + def tick(done: float) -> None: + if p.should_stop and p.should_stop(): + raise InterruptedError + if p.on_progress: + p.on_progress(int(stage[0] + stage[1] * done), str(stage[2])) def node_xyz(ids: np.ndarray) -> np.ndarray: ix = ids % nxs @@ -290,9 +301,11 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: dirs = np.column_stack([fall_x[cy, cx], fall_y[cy, cx]]) r_lo = np.maximum(p.dr, np.floor(0.5 * h / p.dr) * p.dr) r_hi = h + p.max_depth - for ratio in ratios: + for k_ratio, ratio in enumerate(ratios): done = np.zeros(len(ids), bool) - for r in np.arange(r_lo.min(), r_hi.max() + 1e-9, p.dr): + radii = np.arange(r_lo.min(), r_hi.max() + 1e-9, p.dr) + for k_r, r in enumerate(radii): + tick((k_ratio + k_r / len(radii)) / len(ratios)) live = np.flatnonzero(~done & (r_lo <= r + 1e-9) & (r <= r_hi + 1e-9)) if not len(live): continue @@ -365,11 +378,13 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: ids = (grid[:, 0] * nys + grid[:, 1]) * nxs + grid[:, 2] seen.update(ids.tolist()) seeds = run(ids) - rounds = 0 + rounds = passes = 0 while seeds and (step > 1 or rounds < p.refine_rounds): if step == 1: rounds += 1 step = max(1, step // 2) + passes += 1 + stage[:] = [min(40 + 8 * (passes - 1), 77), 8, f"고운 탐색 {passes}"] cand = set() for nid in seeds: ih, rem = divmod(nid, nys * nxs) @@ -394,6 +409,7 @@ def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: limit = max(p.refine_below, 1.2 * best["governing"]) crit = np.unique(node_map[(node_map >= 0) & (f_min < limit)]) if ell and len(crit): + stage[:] = [85, 14, "타원체"] run(crit, ell) f_out = np.where(np.isfinite(f_min), f_min, np.nan) diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 56a21b515..a52acc53d 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -49,8 +49,8 @@ _IDLE = { class SearchSettings(BaseModel): """탐색 조건 — 길이 m · 부피 m³ (Scoops3D 방식 · 요구서 4-2).""" - shape: Literal["sphere", "ellipsoid", "both"] = "both" - ellipsoid_ratio: float = Field(2.0, ge=1.0, le=5.0) # 타원체 = 회전축 방향 반축 배수 + shape: Literal["sphere", "both"] = "both" # both = 구 + 구 임계 노드에서 타원체 + ellipsoid_ratio: float = Field(2.0, ge=1.1, le=5.0) # 타원체 = 회전축 방향 반축 배수 box_half_width_m: float = Field(30.0, ge=5.0, le=100.0) # 탐색 상자 = 중심선 ± 이 폭 cell_m: float = Field(1.0, ge=0.5, le=5.0) # 기둥(셀) 크기 spacing_m: float = Field(2.0, ge=0.5, le=10.0) # 구 중심 수평 간격(가장 고운) @@ -67,7 +67,7 @@ class Analyze3dRequest(BaseModel): start_m: float = Field(..., ge=0.0) end_m: float = Field(..., ge=0.0) search: SearchSettings = SearchSettings() - method: Literal["bishop", "hovland"] = "bishop" # bishop = Hungr 3D Bishop(+Janbu 보정) + condition: Literal["dry", "wet"] = "dry" # 우기 = 지표까지 포화(KDS 우기 규칙) force: bool = False @@ -108,7 +108,7 @@ def _settings(payload: Analyze3dRequest) -> dict[str, Any]: return { "start_m": round(payload.start_m, 2), "end_m": round(payload.end_m, 2), - "method": payload.method, + "condition": payload.condition, **search.model_dump(), } diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 2a0e1a003..8ffc5fbb7 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -8,7 +8,9 @@ grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열 z = 합성 지표(원지형 + 노선면) · f = 셀별 최소 안전율 · v = 그 임계 체적 m³ · m = 방법(0 Bishop · 1 Janbu 보정) · 닿지 않은 셀 = null - best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg, f, volume, method, ix, iy} + best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg(활동 방향), f, volume, method, ix, iy, + hovland · bishop · janbu_corrected(같은 면 방법별 F) · truncated_ratio · floor([셀, 못 뚫는 층 윗면])} + 타원체 = 회전축(활동 방향에 수직) 반축만 ratio·r — 533-2 `trial_columns` 와 같은 꼴. """ from __future__ import annotations @@ -23,11 +25,15 @@ from pathlib import Path from typing import Any import numpy as np +from scipy.spatial import cKDTree -from common_util.common_util_surface_sampler import DtmGridSampler from E01_StabRegister import E01_StabRegister_Source as source +from E02_StabGround.E02_StabGround_Model import load_defaults as load_ground_defaults +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-preview-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e04-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 STAGE_DIR = "E04_Stab3D" GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 @@ -103,59 +109,64 @@ def bbox_of( # --------------------------------------------------------------------------- -# 엔진 — 533-1 · 2 결과 꼴이 오기 전 = 미리보기(지형 경사로 만든 가짜 래스터 · 틀 · 배치 확인용) +# 엔진 — 합성 DEM · 지층(533-1 Grid) → 구 · 타원체 탐색 + 기둥 솔버(533-2 Search · Column) # --------------------------------------------------------------------------- -def _preview_engine( - root: Path, - stations: list[dict[str, Any]], - settings: dict[str, Any], - on_progress: Progress, - should_stop: StopFlag, -) -> dict[str, Any]: - dem = DtmGridSampler.open_cached(source.terrain_grid_path(root)) - x0, x1, y0, y1 = bbox_of(stations, float(settings["box_half_width_m"])) - dx = float(settings["cell_m"]) - xs = np.arange(x0, x1 + dx / 2, dx) - ys = np.arange(y0, y1 + dx / 2, dx) - xx, yy = np.meshgrid(xs, ys) - z, valid = dem.sample_xy(np.column_stack([xx.ravel(), yy.ravel()])) - z = np.where(valid, z, np.nan).reshape(xx.shape) - gy, gx = np.gradient(z, dx) - slope = np.hypot(gx, gy) - f = np.full(z.shape, np.nan) - rows = z.shape[0] - for iy in range(rows): - if should_stop(): - raise InterruptedError - f[iy] = 0.8 + 2.4 / (1.0 + 2.5 * slope[iy] ** 2) - if iy % max(1, rows // 20) == 0: - on_progress(int(100 * iy / rows), f"미리보기 {iy}/{rows} 줄") - volume = np.where(np.isfinite(f), 50.0 + 400.0 * slope**2, np.nan) - iy, ix = np.unravel_index(int(np.nanargmin(f)), f.shape) - r = 10.0 - best = { - "x": float(xs[ix]), - "y": float(ys[iy]), - "z": float(z[iy, ix] + 0.6 * r), - "r": r, - "ratio": 1.0, - "azimuth_deg": 0.0, - "f": float(f[iy, ix]), - "volume": float(volume[iy, ix]), - "method": 0, - } - return { - "grid": {"x0": float(xs[0]), "y0": float(ys[0]), "dx": dx, "nx": len(xs), "ny": len(ys)}, - "z": z, - "f": f, - "v": volume, - "m": np.where(np.isfinite(f), 0.0, np.nan), - "best": best, - "trials": int(np.isfinite(f).sum()), - "preview": True, +def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: + """복사본 측점 → 533-1 측점 한 줄(중심 · 좌표틀 · 지반선 · B06 설계선 · 암 깊이 · 노면).""" + cross = source.cross_section(root, station["chainage_m"]) + row = { + "chainage_m": station["chainage_m"], + "center_x": station["center_x"], + "center_y": station["center_y"], + "frame": cross["frame"], + "samples": cross["samples"], } + return grid_engine.station_from_b06(row, cross["design"]) + + +def _node_mask( + root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float +) -> np.ndarray: + """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안.""" + points = sorted( + source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + ) + line = np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) + # 0.5 m 로 촘촘히 — 꺾인 점 사이 셀도 바른 누가 + dense = [line[:1]] + for a, b in zip(line[:-1], line[1:]): + n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / 0.5))) + t = np.linspace(0, 1, n + 1)[1:, None] + dense.append(a + (b - a) * t) + line = np.vstack(dense) + xx, yy = np.meshgrid(grid["x"], grid["y"]) + dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) + chain = line[at, 2] + keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6) + return keep.reshape(xx.shape) + + +def _floor(strata: Any, best: dict[str, Any]) -> list[list[float]]: + """임계면이 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] (화면 활동면 메쉬가 그 높이로 자름).""" + xx, yy = np.meshgrid(strata.x, strata.y) + cx, cy, cz = best["center"] + d = np.asarray(best.get("direction") or [0.0, 1.0], float) + pp = (xx - cx) * d[0] + (yy - cy) * d[1] + qq = (-(xx - cx) * d[1] + (yy - cy) * d[0]) / max(float(best["ratio"]), 1e-9) + rest = float(best["radius"]) ** 2 - pp**2 - qq**2 + slip = np.where(rest >= 0, cz - np.sqrt(np.maximum(rest, 0)), np.nan) + hard = strata.hard_top + hit = np.isfinite(slip) & (slip < strata.z_top) & (hard > slip) & (hard < strata.z_top) + return [[int(i), round(float(hard.ravel()[i]), 2)] for i in np.flatnonzero(hit.ravel())] + + +def reference_fs(ground: dict[str, Any] | None, condition: str) -> float: + """기준안전율(E02 · 없으면 마스터 기본) — 깎기 · 쌓기 중 큰 값(보수).""" + factors = (ground or load_ground_defaults()).get("safety_factors") or {} + row = factors.get(condition) or {} + return float(max(row.get("cut", 1.5), row.get("fill", 1.5))) def run_engine( @@ -166,8 +177,80 @@ def run_engine( on_progress: Progress, should_stop: StopFlag, ) -> dict[str, Any]: - """엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}.""" - return _preview_engine(root, stations, settings, on_progress, should_stop) + """엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}. + + stations = 구간 측점 + 앞뒤 이웃 하나씩(구간 끝 합성 지형이 이어지게). + """ + on_progress(1, "합성 지형") + half = float(settings["box_half_width_m"]) + margin = half + float(settings["max_depth_m"]) + 10.0 # 활동체가 격자 끝에 닿지 않게 + with np.load(source.terrain_grid_path(root)) as npz: + dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")} + grid = grid_engine.build_grid( + dem, + [_engine_station(root, s) for s in stations], + bbox=bbox_of(stations, margin), + road_q_kpa=float((ground or {}).get("road_surcharge_kpa", DEFAULT_ROAD_SURCHARGE_KPA)), + cell_m=float(settings["cell_m"]), + ) + wet = settings["condition"] == "wet" + strata = grid_engine.strata_from_grid( + grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None + ) + mask = _node_mask(root, grid, settings["start_m"], settings["end_m"], half) + ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"])) + params = search_engine.SearchParams( + node_mask=mask, + spacing=float(settings["spacing_m"]), + h_min=float(settings["h_min_m"]), + h_max=float(settings["h_max_m"]), + h_step=float(settings["h_step_m"]), + dr=float(settings["dr_m"]), + max_depth=float(settings["max_depth_m"]), + v_min=float(settings["v_min_m3"]), + v_max=float(settings["v_max_m3"]), + ratios=ratios, + groundwater={"mode": "line"} if wet else None, # 지하수위면 = strata.z_water + on_progress=lambda pct, msg: on_progress(2 + int(pct * 0.97), msg), + should_stop=should_stop, + ) + res = search_engine.search(strata, params) + best = res.best + if not math.isfinite(float(best.get("governing", math.inf))): + raise ValueError("조건에 맞는 활동면이 없습니다 — 부피 · 높이 범위를 넓히세요.") + d = best.get("direction") or [0.0, 1.0] + cx, cy, cz = best["center"] + return { + "grid": { + "x0": float(strata.x[0]), + "y0": float(strata.y[0]), + "dx": float(strata.dx), + "nx": len(strata.x), + "ny": len(strata.y), + }, + "z": strata.z_top, + "f": res.f_min, + "v": res.volume, + "m": np.where(res.method >= 0, res.method, np.nan), + "best": { + "x": float(cx), + "y": float(cy), + "z": float(cz), + "r": float(best["radius"]), + "ratio": float(best["ratio"]), + "azimuth_deg": math.degrees(math.atan2(float(d[0]), float(d[1]))) % 360.0, + "f": float(best["governing"]), + "volume": float(best["volume"]), + "method": int(best["method"]), + "hovland": float(best["hovland"]), + "bishop": float(best["bishop"]), + "janbu_corrected": float(best["janbu_corrected"]), + "truncated_ratio": float(best["truncated_ratio"]), + "floor": _floor(strata, best), + }, + "trials": int(res.trials), + "reference_fs": reference_fs(ground, settings["condition"]), + } # --------------------------------------------------------------------------- @@ -201,7 +284,6 @@ def pack( return { "fingerprint": finger, "engine": ENGINE_VERSION, - "preview": bool(raw.get("preview")), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(elapsed_s, 2), @@ -217,6 +299,12 @@ def pack( "f_min": round(float(best["f"]), 3), "volume": round(float(best["volume"]), 1), "method": METHOD_NAMES[int(best.get("method") or 0)], + "condition": settings["condition"], + "reference_fs": raw["reference_fs"], + "verdict": "ok" if best["f"] >= raw["reference_fs"] else "ng", + "by_method": { + k: round(float(best[k]), 3) for k in ("hovland", "bishop", "janbu_corrected") + }, "chainage_m": float(_nearest_chainage(stations, best["x"], best["y"])), "cells": int(np.isfinite(f).sum()), "trials": int(raw.get("trials") or 0), @@ -247,7 +335,6 @@ def saved_summaries(project_root: Path) -> list[dict[str, Any]]: "key": path.stem, "fingerprint": doc["fingerprint"], "settings": doc["settings"], - "preview": doc.get("preview", False), **doc["summary"], } ) @@ -264,12 +351,14 @@ def analyze( ) -> dict[str, Any]: """한 구간 3D 해석 → 저장본 쓰고 요약 돌려줌.""" started = time.perf_counter() - stations = source.stations(root) + stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) picked = section_stations(stations, settings["start_m"], settings["end_m"]) if len(picked) < 2: raise ValueError("구간 안 측점이 둘 미만입니다.") + first, last = stations.index(picked[0]), stations.index(picked[-1]) + around = stations[max(first - 1, 0) : last + 2] # 앞뒤 이웃 — 구간 끝 합성 지형 이음 ground = read_ground(project_root) - raw = run_engine(root, picked, ground, settings, on_progress, should_stop) + 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) return result["summary"] diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index 8cad3b057..858502ba9 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -35,7 +35,6 @@ interface SavedSummary { volume: number; method: string; stale: boolean; - preview: boolean; } interface Overview { @@ -302,10 +301,19 @@ export function renderE04Stab3D(root: HTMLElement): void { ); const rows: [LocaleKey, string, string?][] = [ ["E04_Result_F3d", s.f_min.toFixed(3), stepColor(FS_STEPS, s.f_min)], + [ + "E04_Result_Verdict", + `${t(s.verdict === "ok" ? "E04_Verdict_ok" : "E04_Verdict_ng")} (${t( + `E04_Condition_${s.condition}` as LocaleKey, + )} ≥ ${s.reference_fs.toFixed(2)})`, + ], ["E04_Result_F2d", f2d === null ? t("E04_No2D") : f2d.toFixed(3)], ["E04_Result_Ratio", ratio === null ? "-" : ratio.toFixed(3)], ["E04_Result_Volume", `${Math.round(s.volume).toLocaleString()} m³`], ["E04_Result_Method", t(`E04_MethodName_${s.method}` as LocaleKey)], + ["E04_Result_Bishop", s.by_method.bishop.toFixed(3)], + ["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)], + ["E04_Result_Hovland", s.by_method.hovland.toFixed(3)], ["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`], ["E04_Result_Cells", s.cells.toLocaleString()], ["E04_Result_Trials", s.trials.toLocaleString()], @@ -314,6 +322,7 @@ export function renderE04Stab3D(root: HTMLElement): void { const list = el("dl", { className: "e04-result__list" }); for (const [key, value, color] of rows) { const dd = el("dd", { text: value, attrs: { "data-field": key } }); + if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`); if (color) { const chip = el("i", { className: "e04-section__chip" }); chip.style.background = color; @@ -323,8 +332,7 @@ export function renderE04Stab3D(root: HTMLElement): void { } const notes: HTMLElement[] = []; if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") })); - if (result.preview) - notes.push(el("p", { className: "e04-note", text: t("E04_Result_Preview") })); + notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") })); resultBody.replaceChildren(list, ...notes); } @@ -371,11 +379,11 @@ export function renderE04Stab3D(root: HTMLElement): void { async function analyze(force: boolean): Promise { const values = settings.read(); - const { start_m, end_m, method, ...search } = values; + const { start_m, end_m, condition, ...search } = values; try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, { method: "POST", - body: JSON.stringify({ start_m, end_m, method, search, force }), + body: JSON.stringify({ start_m, end_m, condition, search, force }), }); if (body.status === "cached") { showToast(t("E04_Cached"), "info"); @@ -415,7 +423,7 @@ export function renderE04Stab3D(root: HTMLElement): void { ...ov.defaults, start_m: first?.start_m ?? 0, end_m: first?.end_m ?? 100, - method: "bishop", + condition: "dry", }, ); map.setRoute(ov.line, ov.stations); diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts index 69377dded..215201303 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Settings.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -1,6 +1,6 @@ /* ============================================================================= * E04_Stab3D_UI_Settings.ts - * E04 좌측 탐색 설정 칸(PLAN 533-3) — 구간 시작 · 끝 · 기본(모양 · 탐색 폭 · 방법) · 고급(접힘 · + * E04 좌측 탐색 설정 칸(PLAN 533-3) — 구간 시작 · 끝 · 기본(조건 · 모양 · 탐색 폭) · 고급(접힘 · * 기둥 · 구 중심 격자 · 반지름 증분 · 깊이 · 부피 제약) · [기본값]. 기본값 = 서버 SearchSettings. * ========================================================================== */ @@ -17,7 +17,7 @@ import { t, type LocaleKey } from "@ui/ui_template_locale"; export interface Settings { start_m: number; end_m: number; - method: string; + condition: string; shape: string; ellipsoid_ratio: number; box_half_width_m: number; @@ -32,7 +32,7 @@ export interface Settings { v_max_m3: number; } -type NumberKey = Exclude; +type NumberKey = Exclude; const BASIC: [NumberKey, LocaleKey][] = [ ["ellipsoid_ratio", "E04_Ratio"], @@ -69,23 +69,23 @@ export function buildSettingsCards() { const shape = createSelectField({ label: t("E04_Shape"), compact: true, - options: (["both", "sphere", "ellipsoid"] as const).map((v) => ({ + options: (["both", "sphere"] as const).map((v) => ({ value: v, text: t(`E04_Shape_${v}` as LocaleKey), })), onChange: changed, }); - const method = createSelectField({ - label: t("E04_Method"), + const condition = createSelectField({ + label: t("E04_Condition"), compact: true, - options: (["bishop", "hovland"] as const).map((v) => ({ + options: (["dry", "wet"] as const).map((v) => ({ value: v, - text: t(`E04_Method_${v}` as LocaleKey), + text: t(`E04_Condition_${v}` as LocaleKey), })), onChange: changed, }); const basicCard = createPanelCard(t("E04_Card_Search"), false); - basicCard.append(shape.root, ...BASIC.map(([k, l]) => number(k, l)), method.root); + basicCard.append(condition.root, shape.root, ...BASIC.map(([k, l]) => number(k, l))); let defaults: Partial = {}; const advancedCard = createPanelCard(t("E04_Card_Advanced"), true); @@ -99,14 +99,14 @@ export function buildSettingsCards() { size: "sm", onClick: () => { const range = { start_m: read().start_m, end_m: read().end_m }; - write({ ...read(), ...defaults, ...range, method: "bishop" }); + write({ ...read(), ...defaults, ...range, condition: "dry" }); changed(); }, }), ); function read(): Settings { - const out = { shape: shape.select.value, method: method.select.value } as Settings; + const out = { shape: shape.select.value, condition: condition.select.value } as Settings; for (const [key, field] of fields) out[key] = Number(field.input.value); return out; } @@ -117,7 +117,7 @@ export function buildSettingsCards() { if (typeof value === "number" && Number.isFinite(value)) field.input.value = String(value); } if (values.shape) shape.select.value = values.shape; - if (values.method) method.select.value = values.method; + if (values.condition) condition.select.value = values.condition; } return { diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index 74fc63e89..7b0c9b7ca 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -266,6 +266,14 @@ justify-content: flex-end; } +.e04-result__list dd.is-ok { + color: var(--color-success); +} + +.e04-result__list dd.is-ng { + color: var(--color-danger); +} + .e04-warn { margin: var(--spacing-12) 0 0; color: var(--color-danger); diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts index 7ff8d3c66..b191f4e79 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -70,19 +70,22 @@ export function slipHeight(best: SlipBest, x: number, y: number): number | null return rest < 0 ? null : best.z - Math.sqrt(rest); } -/** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만) */ +/** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만 · 못 뚫는 층 윗면 아래로는 안 내려감) */ export function cutCells( grid: Grid, z: readonly (number | null)[], best: SlipBest, ): Map { + const floor = new Map(best.floor ?? []); const out = new Map(); z.forEach((top, index) => { if (top === null) return; const x = grid.x0 + (index % grid.nx) * grid.dx; const y = grid.y0 + Math.floor(index / grid.nx) * grid.dx; const slip = slipHeight(best, x, y); - if (slip !== null && slip < top) out.set(index, slip); + if (slip === null) return; + const base = Math.max(slip, floor.get(index) ?? -Infinity); + if (base < top) out.set(index, base); }); return out; } diff --git a/E04_Stab3D/logic/E04_Stab3D_Raster.ts b/E04_Stab3D/logic/E04_Stab3D_Raster.ts index dd73bdaac..a98e95df8 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Raster.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Raster.ts @@ -26,6 +26,8 @@ export interface SlipBest { method: number; ix: number; iy: number; + /** 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] · 활동면을 그 높이에서 자름 */ + floor?: [number, number][]; } export interface Summary3d { @@ -37,12 +39,16 @@ export interface Summary3d { chainage_m: number; cells: number; trials: number; + condition: string; + reference_fs: number; + verdict: "ok" | "ng"; + /** 같은 임계면의 방법별 안전율 */ + by_method: { hovland: number; bishop: number; janbu_corrected: number }; } export interface Result3d { key: string; stale: boolean; - preview: boolean; computed_at: string; elapsed_s: number; settings: Record; diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py new file mode 100644 index 000000000..9e3497618 --- /dev/null +++ b/resources/tester/test_533_3_e04_engine.py @@ -0,0 +1,180 @@ +"""E04 엔진 잇기(PLAN 533-3) — 복사본 측점 → 533-1 합성 지형 → 533-2 탐색 → 저장본 꼴 · +탐색 노드 = 구간 · 탐색 폭 안 · 진행률 · 끊기 · 고리를 달아도 결과 불변. + +./venv/Scripts/python.exe -m pytest resources/tester/test_533_3_e04_engine.py -q +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from E04_Stab3D import E04_Stab3D_Run as run +from E04_Stab3D.E04_Stab3D_Engine_Column import Strata +from E04_Stab3D.E04_Stab3D_Engine_Search import SearchParams, search + +# 노선 = x 축(동쪽) · 왼쪽(북) = 오르막 0.6 · 측점 20 m +CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 100.0] +SLOPE = 0.6 + + +def _ground(y): + return 100.0 + SLOPE * y + + +def _cross(ch: float) -> dict: + offsets = np.arange(-25.0, 25.01, 0.5) + samples = [{"offset_m": o, "z": _ground(o), "valid": True} for o in offsets] + # 노면 |o| ≤ 3 평평(100) · 왼쪽 1:1 깎기 · 오른쪽 1:1.5 쌓기 · 바깥 = 지반 + line = [] + for o in offsets: + if abs(o) <= 3: + z = 100.0 + elif o > 0: + z = min(100.0 + (o - 3), _ground(o)) + else: + z = max(100.0 - (-o - 3) / 1.5, _ground(o)) + line.append({"offset_m": o, "elevation_m": z}) + return { + "chainage_m": ch, + "frame": {"left_xy": [0.0, 1.0]}, + "samples": samples, + "design": { + "design_line": line, + "ground_type": "soil", + "road_edges": {"left": {"offset_m": 3.0}, "right": {"offset_m": -3.0}}, + }, + } + + +STATIONS = [ + {"chainage_m": ch, "label": f"No.{i}", "center_x": ch, "center_y": 0.0} + for i, ch in enumerate(CHAINAGES) +] + +SETTINGS = { + "start_m": 20.0, + "end_m": 60.0, + "condition": "dry", + "shape": "both", + "ellipsoid_ratio": 2.0, + "box_half_width_m": 12.0, + "cell_m": 1.0, + "spacing_m": 3.0, + "h_min_m": 2.0, + "h_max_m": 10.0, + "h_step_m": 2.0, + "dr_m": 1.0, + "max_depth_m": 6.0, + "v_min_m3": 5.0, + "v_max_m3": 3000.0, +} + + +@pytest.fixture +def root(tmp_path, monkeypatch): + models = tmp_path / "B04_PreProcess" / "models" + models.mkdir(parents=True) + xs = np.arange(-60.0, 160.0, 1.0) + ys = np.arange(-60.0, 60.0, 1.0) + xx, yy = np.meshgrid(xs, ys) + z = _ground(yy) + np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) + monkeypatch.setattr(run.source, "cross_section", lambda r, ch: _cross(float(ch))) + monkeypatch.setattr( + run.source, + "db_snapshot", + lambda r: { + "route_points": [ + {"model_x": ch, "model_y": 0.0, "chainage_m": ch, "sequence_num": i} + for i, ch in enumerate(CHAINAGES) + ] + }, + ) + return tmp_path + + +def test_engine_raster_and_best(root): + seen: list[tuple[int, str]] = [] + raw = run.run_engine( + root, STATIONS[0:5], None, SETTINGS, lambda p, m: seen.append((p, m)), lambda: False + ) + grid = raw["grid"] + f = raw["f"] + assert f.shape == (grid["ny"], grid["nx"]) == raw["z"].shape + assert grid["dx"] == 1.0 + # 격자 = 구간 측점 ± (탐색 폭 + 깊이 + 10) + assert grid["x0"] == pytest.approx(0.0 - 28.0, abs=1.0) + finite = np.isfinite(f) + assert finite.sum() > 50 + best = raw["best"] + iy = round((best["y"] - grid["y0"]) / grid["dx"]) + ix = round((best["x"] - grid["x0"]) / grid["dx"]) + # 전체 최소 = 래스터 최소(그 활동체가 덮는 셀 중 하나) · 중심 = 탐색 띠 안(x 20~60 · |y| ≤ 12) + assert np.nanmin(f) == pytest.approx(best["f"], rel=1e-6) + assert 20.0 - 1e-6 <= best["x"] <= 60.0 + 1e-6 and abs(best["y"]) <= 12.0 + 1e-6 + assert 0 <= ix < grid["nx"] and 0 <= iy < grid["ny"] + assert best["r"] > 0 and best["ratio"] in (1.0, 2.0) and 0 <= best["azimuth_deg"] < 360 + assert best["f"] == best["bishop"] or best["method"] == 1 # 다스림 = Bishop(잘림 없음) + assert raw["reference_fs"] == 1.5 # 마스터 기본 건기 + # 합성 지표 — 노면 위(측점 40 · o=0) = 설계 100 · 깎기 끝 밖(o=+20 · 원지형) = 지반 + z = raw["z"] + jy0 = round((0.0 - grid["y0"]) / grid["dx"]) + jx40 = round((40.0 - grid["x0"]) / grid["dx"]) + assert z[jy0, jx40] == pytest.approx(100.0, abs=0.05) + jyw = round((20.0 - grid["y0"]) / grid["dx"]) + assert z[jyw, jx40] == pytest.approx(_ground(20.0), abs=0.05) + # 진행률 — 0~100 · 단계 이름 · 마지막이 탐색 끝 무렵 + assert seen and all(0 <= p <= 100 for p, _ in seen) and seen[-1][0] >= 80 + + +def test_engine_stop(root): + calls = {"n": 0} + + def stop(): + calls["n"] += 1 + return calls["n"] > 3 + + with pytest.raises(InterruptedError): + run.run_engine(root, STATIONS[0:5], None, SETTINGS, lambda p, m: None, stop) + + +def test_wet_lower_than_dry(root): + dry = run.run_engine(root, STATIONS[0:5], None, SETTINGS, lambda p, m: None, lambda: False) + wet = run.run_engine( + root, + STATIONS[0:5], + None, + {**SETTINGS, "condition": "wet"}, + lambda p, m: None, + lambda: False, + ) + assert wet["best"]["f"] < dry["best"]["f"] + assert wet["reference_fs"] == 1.3 # 우기 깎기 1.2 · 쌓기 1.3 중 큰 값 + + +def test_hooks_do_not_change_search(): + """533-2 탐색에 단 진행률 · 끊기 고리 — 결과 불변(sub5 시험 꼴 작은 비탈).""" + x = np.arange(-15.0, 35.0, 1.0) + y = np.arange(-15.0, 15.0, 1.0) + xx, yy = np.meshgrid(x, y) + zz = np.clip(xx * 0.5, 0, 10) + mat = [{"id": "m", "name": "m", "gamma_t": 19.0, "gamma_sat": 19.0, "c": 10.0, "phi": 25.0}] + strata = Strata(x, y, zz, np.zeros((0,) + zz.shape), mat) + base = dict(box=(-5, 22, -1, 1), spacing=2.0, h_min=2, h_max=14, h_step=2, dr=1.0, + max_depth=10, v_min=20, v_max=2e4, ratios=(1.0,)) # fmt: skip + plain = search(strata, SearchParams(**base)) + ticks: list[int] = [] + hooked = search( + strata, + SearchParams(**base, on_progress=lambda p, m: ticks.append(p), should_stop=lambda: False), + ) + assert hooked.best["governing"] == plain.best["governing"] + assert np.array_equal(np.isnan(hooked.f_min), np.isnan(plain.f_min)) + assert np.nanmax(np.abs(hooked.f_min - plain.f_min)) == 0 + assert ticks and min(ticks) >= 0 and max(ticks) <= 100 + with pytest.raises(InterruptedError): + search(strata, SearchParams(**base, should_stop=lambda: True)) + assert math.isfinite(plain.best["governing"]) diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index dc95b46d5..380ba706a 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -60,6 +60,8 @@ writeFileSync(process.argv[3], JSON.stringify({ sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)), ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)), cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()), + floored: Object.fromEntries(M.cutCells(c.grid, c.z, + { ...c.sphere, floor: [[7 * 21 + 10, 53.0], [7 * 21 + 11, 60.0]] }).entries()), holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))), hCenter: M.slipHeight(c.sphere, 110, 207), hEdge: M.slipHeight(c.sphere, 116.5, 207), @@ -178,6 +180,14 @@ def test_slip_mesh_vertices(ts, name): assert all(z < Z[i] for i, z in cut.items()) +def test_floor_truncates_slip(ts): + """못 뚫는 층 — 활동면이 층 윗면 아래로 안 내려감 · 층이 지표보다 높으면 잘린 셀 아님.""" + center = 7 * 21 + 10 + assert ts["floored"][str(center)] == pytest.approx(53.0) # 활동면 52 → 층 53 + assert str(center + 1) not in ts["floored"] # 층 60 ≥ 지표 53.5 + assert ts["floored"][str(center - 1)] == pytest.approx(dict(_py_cut(SPHERE))[center - 1]) + + def test_terrain_hole_matches_cut(ts): """활동체를 걷어낸 지표 = 지표 꼭짓점 − 잘린 셀 · 구멍 테두리는 활동면 메쉬와 같은 꼭짓점.""" cut = [i for i, _ in _py_cut(SPHERE)] diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 31a69f097..aac24b02b 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -24,6 +24,27 @@ STATIONS = [ ] +def _fake_engine(root, stations, ground, settings, on_progress, should_stop): + """엔진 자리 — 길 시험은 셈 내용이 아니라 흐름(지문 · 끊기 · 저장)을 봄(엔진 = test_533_3_e04_engine).""" + on_progress(50, "가짜") + f = np.array([[1.4, 1.2, np.nan], [1.1, 0.9, 1.6]]) + return { + "grid": {"x0": 100.0, "y0": 200.0, "dx": 1.0, "nx": 3, "ny": 2}, + "z": np.full(f.shape, 500.0), + "f": f, + "v": np.where(np.isfinite(f), 120.0, np.nan), + "m": np.where(np.isfinite(f), 0.0, np.nan), + "best": { + "x": 101.0, "y": 201.0, "z": 505.0, "r": 6.0, "ratio": 1.0, "azimuth_deg": 0.0, + "f": 0.9, "volume": 120.0, "method": 0, + "hovland": 0.85, "bishop": 0.9, "janbu_corrected": 0.95, "truncated_ratio": 0.0, + "floor": [], + }, + "trials": 7, + "reference_fs": 1.5, + } # fmt: skip + + @pytest.fixture def project(tmp_path, monkeypatch): src = tmp_path / "E01_StabRegister" / "source" @@ -55,6 +76,8 @@ def project(tmp_path, monkeypatch): async def fake_root(project_id): return tmp_path + monkeypatch.setattr(run, "run_engine", _fake_engine) + monkeypatch.setattr(router_mod, "_project_root", fake_root) router_mod._JOBS.clear() return tmp_path @@ -102,7 +125,8 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): assert first == {"status": "accepted", "key": "0.00_100.00"} done = _wait(client) assert done["error"] is None and done["percent"] == 100 - assert done["summary"]["cells"] > 0 + assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng" + assert done["summary"]["by_method"] == {"hovland": 0.85, "bishop": 0.9, "janbu_corrected": 0.95} # 같은 입력 = 셈하지 않음 again = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() assert again["status"] == "cached" and len(calls) == 1 @@ -174,13 +198,7 @@ def test_cancel_and_restart(client, monkeypatch): == 404 ) # 다시 시작 - monkeypatch.setattr( - run, - "run_engine", - run._preview_engine.__wrapped__ - if hasattr(run._preview_engine, "__wrapped__") - else lambda *a: run._preview_engine(a[0], a[1], a[3], a[4], a[5]), - ) + monkeypatch.setattr(run, "run_engine", _fake_engine) assert ( client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()["status"] == "accepted" ) diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index b541c2c9f..49c898651 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -22,7 +22,6 @@ export const ui_locales_e04 = { E04_End: ["끝 (m)", "End (m)"], E04_Shape: ["활동면 모양", "Slip shape"], E04_Shape_sphere: ["구", "Sphere"], - E04_Shape_ellipsoid: ["타원체", "Ellipsoid"], E04_Shape_both: ["구 + 타원체", "Sphere + ellipsoid"], E04_Ratio: ["타원체 길이비", "Ellipsoid ratio"], E04_BoxWidth: ["탐색 폭 (중심선 ±m)", "Search half-width (m)"], @@ -35,9 +34,9 @@ export const ui_locales_e04 = { E04_MaxDepth: ["최대 깊이 (m)", "Max depth (m)"], E04_VMin: ["최소 부피 (m³)", "Min volume (m³)"], E04_VMax: ["최대 부피 (m³)", "Max volume (m³)"], - E04_Method: ["해석 방법", "Method"], - E04_Method_bishop: ["3D Bishop (Hungr) + Janbu 보정", "3D Bishop (Hungr) + corrected Janbu"], - E04_Method_hovland: ["Hovland (비교)", "Hovland (comparison)"], + E04_Condition: ["조건", "Condition"], + E04_Condition_dry: ["건기", "Dry"], + E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"], E04_Defaults: ["기본값", "Defaults"], E04_Analyze: ["해석", "Analyze"], E04_Reanalyze: ["다시 해석", "Re-analyze"], @@ -78,10 +77,16 @@ export const ui_locales_e04 = { "입력(지반 조건 · 탐색 설정 · 엔진)이 바뀌었습니다 — 다시 해석하세요.", "Inputs changed — re-analyze.", ], - E04_Result_Preview: [ - "미리보기 엔진 결과(3D 엔진 연결 전 · 지형 경사로 만든 값)", - "Preview engine (slope-based placeholder)", + E04_Result_Note: [ + "3D/2D 비 = 같은 구간 3D 최소 ÷ E03 측점 최소 · 방법별 = 같은 임계면 · 물성 = 문헌 추정치(현장 시험 없음).", + "3D/2D = 3D min ÷ E03 station min · per-method values on the same surface · literature properties.", ], + E04_Result_Verdict: ["판정", "Verdict"], + E04_Verdict_ok: ["만족", "OK"], + E04_Verdict_ng: ["불만족", "NG"], + E04_Result_Bishop: [" 3D Bishop", " 3D Bishop"], + E04_Result_Janbu: [" 3D Janbu 보정", " 3D corrected Janbu"], + E04_Result_Hovland: [" Hovland (비교)", " Hovland (comparison)"], E04_No2D: ["2D 결과 없음", "No 2D result"], E04_MethodName_bishop: ["3D Bishop", "3D Bishop"], E04_MethodName_janbu_corrected: ["3D Janbu 보정", "3D corrected Janbu"], From 8348e6b922bba13876d0cf2ffa01164091a9eb16 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:01:50 +0900 Subject: [PATCH 04/34] =?UTF-8?q?fix(532-3):=20E03=20=EC=9D=BC=EA=BE=BC=20?= =?UTF-8?q?stderr=20=ED=8C=8C=EC=9D=BC=EB=A1=9C(=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=20=EB=A7=89=ED=9E=98=EC=9C=BC=EB=A1=9C=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D=20=EB=A9=88=EC=B6=A4)=20=C2=B7=20=EC=B2=98=EC=9D=8C?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=3D=20=EA=B7=B8=20=EB=B9=84=ED=83=88=20?= =?UTF-8?q?=ED=99=95=EB=8C=80=20=C2=B7=20=ED=99=94=EB=A9=B4=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=EB=AC=BC=EC=B2=B4=EB=8F=84=20=EB=B0=98=EB=A0=A5=20=ED=99=94?= =?UTF-8?q?=EC=82=B4=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일꾼 stderr 를 파이프로 두면 자식 경고가 쌓여 8/57 에서 멈춤 → 임시 파일로 - 횡단 처음 보기 = 탐색 범위 · 임계면 · 격자 중심을 판 가운데(두 번 누름 = 이 보기 · 줌 아웃 = 모형 전체) - 본문 높이 = 화면 높이(커지며 넘치던 것) · 고른 줄 표시 밝음/어두움 같이 · 3D 보기 창 맨 위 - 자유물체도 N · U · S = 바닥으로 들어오는 화살 · 글 = 화살 바깥 끝 - 목록 측점 0+000 꼴 · 좌우 깎/쌓 · 해석 없음 = - Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_Router.py | 35 ++++++++------- E03_StabSection/E03_StabSection_UI_List.ts | 12 ++++-- E03_StabSection/E03_StabSection_UI_Page.ts | 2 + E03_StabSection/E03_StabSection_UI_Result.ts | 11 ++++- E03_StabSection/E03_StabSection_UI_Section.ts | 7 ++- E03_StabSection/E03_StabSection_UI_Style.css | 16 ++++--- E03_StabSection/E03_StabSection_UI_View3D.ts | 2 +- .../logic/E03_StabSection_Logic.ts | 43 +++++++++++++++++-- ui_template/ui_template_locale_e03.ts | 2 + 9 files changed, 98 insertions(+), 32 deletions(-) diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 573c922a9..7e8b3c17b 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -170,24 +170,29 @@ def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]: def _run_worker(job: dict[str, Any], emit: Any) -> None: - """일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError.""" + """일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError. + + stderr 는 파일로(파이프로 두면 경고가 쌓여 일꾼이 멈춤).""" with tempfile.TemporaryDirectory(prefix="aislo_e03_") as folder: path = Path(folder) / "job.json" path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8") - process = subprocess.Popen( - [sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)], - cwd=PROJECT_ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - ) - for line in process.stdout: - if line.startswith("{"): - emit(json.loads(line).get("done")) - error = process.stderr.read() - if process.wait() != 0: - raise RuntimeError(f"해석 일꾼 실패: {error.strip().splitlines()[-1:]}") + log = Path(folder) / "stderr.txt" + with log.open("w", encoding="utf-8") as errors: + process = subprocess.Popen( + [sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=errors, + text=True, + encoding="utf-8", + ) + for line in process.stdout: + if line.startswith("{"): + emit(json.loads(line).get("done")) + code = process.wait() + if code != 0: + tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:] + raise RuntimeError(f"해석 일꾼 실패: {tail}") def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 52c86ea45..4a88a343e 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -82,16 +82,22 @@ export function buildList( onToggle(row.key); }); tr.append( - el("td", { text: row.label?.replace(/^STA\./, "") ?? row.key }), + el("td", { text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key }), el("td", { - text: row.sides.length ? roleText(row, { cut: t("E03_Cut"), fill: t("E03_Fill") }) : "-", + text: row.sides.length + ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) + : "-", }), ...draw.conditions.map((c) => el("td", { className: "is-num", text: fmt(row.min[c]) })), el("td", { children: [ el("span", { className: `e03-verdict e03-verdict--${v}`, - text: row.error ? t("E03_Error") : t(`E03_Verdict_${v}` as LocaleKey), + text: row.error + ? t("E03_Error") + : v === "none" + ? "-" + : t(`E03_Verdict_${v}` as LocaleKey), }), ], }), diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 4287bfa09..53bc15939 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -331,7 +331,9 @@ export function renderE03StabSection(root: HTMLElement): void { } async function load(): Promise { + const before = result?.status; result = await api(`${base}/result2d`); + if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로 sections.clear(); const conds = result.conditions; if (!conds.includes(condition)) condition = conds[0] ?? "dry"; diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts index a9225f2aa..18f41337a 100644 --- a/E03_StabSection/E03_StabSection_UI_Result.ts +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -174,11 +174,18 @@ function freeBody(view: SectionView, analysis: Analysis, picked: number | null): "data-value": String(a.value), }), ); - const tip = a.name.startsWith("E") || a.name.startsWith("X") || a.name === "Q" ? p0 : p1; + // 글 = 화살 바깥 끝(들어오는 힘 = 꼬리 · 나가는 힘 = 머리)에서 화살 방향으로 조금 더 + const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) || 1; + const d: Point = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len]; + const ending = a.name !== "W"; + const out: Point = ending ? [-d[0], -d[1]] : d; + const at: Point = ending ? p0 : p1; + const anchor = out[0] > 0.3 ? "start" : out[0] < -0.3 ? "end" : "middle"; + const label = { x: at[0] + out[0] * 6, y: at[1] + out[1] * 6 + (out[1] > 0.3 ? 10 : 0) }; svg.append( svgEl( "text", - { x: tip[0] + 4, y: tip[1] - 4, class: "e03-label" }, + { ...label, "text-anchor": anchor, class: "e03-label" }, `${a.name} ${fmt(a.value, 1)}`, ), ); diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index 7960d5042..273d9f866 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -13,6 +13,7 @@ import { bandIndex, bandLegend, fitView, + focusHome, pointsText, sliceShape, toModel, @@ -25,6 +26,7 @@ import { const SVG_NS = "http://www.w3.org/2000/svg"; const PAD = 28; +const MAX_SCALE = 30; export function svgEl( tag: K, @@ -307,7 +309,7 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec }); svg.addEventListener("mouseleave", () => (tip.style.display = "none")); - const key = `${view.key}|${width}x${height}`; + const key = `${view.key}|${analysis?.condition}|${analysis?.side}|${width}x${height}`; if (key !== zoomKey) zoom = undefined; zoomKey = key; attachSvgZoomPan({ @@ -317,7 +319,8 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec heightPx: height, pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true, - maxScale: 20, + maxScale: MAX_SCALE, + home: focusHome(fit, view, analysis, width, height, MAX_SCALE), initial: zoom, onChange: (state) => (zoom = state), }); diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index 95b8a2665..63c515748 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -80,7 +80,7 @@ display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--spacing-8); - height: 100%; + height: calc(100dvh - var(--app-header-h)); padding: var(--spacing-8) var(--spacing-12); box-sizing: border-box; min-height: 0; @@ -89,6 +89,7 @@ .e03-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 420px); + grid-template-rows: minmax(0, 1fr); gap: var(--spacing-8); min-height: 0; } @@ -214,9 +215,10 @@ } .e03-list tbody tr.is-active { - background: var(--color-lavender-wash); - outline: 1px solid var(--color-accent); - outline-offset: -1px; + background: var(--color-surface-sunken); + outline: 2px solid var(--color-accent); + outline-offset: -2px; + font-weight: var(--font-weight-medium); } .e03-verdict { @@ -508,7 +510,9 @@ } .e03-table tbody tr.is-picked { - background: var(--color-lavender-wash); + background: var(--color-surface-sunken); + outline: 2px solid var(--color-accent); + outline-offset: -2px; } .e03-slices { @@ -576,7 +580,7 @@ .e03-view3d { position: fixed; inset: 5vh 5vw; - z-index: 50; + z-index: 800; display: flex; flex-direction: column; border-radius: var(--radius-sm); diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts index 4f69b9229..4042d0d22 100644 --- a/E03_StabSection/E03_StabSection_UI_View3D.ts +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -149,7 +149,7 @@ export async function openView3d( const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000)); camera.position .copy(sphere.center) - .add(new THREE.Vector3(0, sphere.radius * 1.4, sphere.radius * 1.2)); + .add(new THREE.Vector3(0, sphere.radius * 1.9, sphere.radius * 1.6)); const controls = new OrbitControls(camera, renderer.domElement); controls.target.copy(sphere.center); controls.update(); diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index 9bf1903dd..134e8ade0 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -243,6 +243,43 @@ export function fitView(view: SectionView, width: number, height: number, pad: n return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 }; } +/** + * 처음 보기(줌 · 팬) — 그 비탈 탐색 범위 · 임계면 · 격자 중심을 판 가운데 크게(여백 25 %). + * 판 전체(scale 1) = 모형 전체 · 두 번 누름 = 이 보기 · 배율은 1~maxScale. + */ +export function focusHome( + fit: ViewFit, + view: SectionView, + analysis: Analysis | null, + width: number, + height: number, + maxScale: number, +): { scale: number; tx: number; ty: number } { + if (!analysis) return { scale: 1, tx: 0, ty: 0 }; + const pts: Point[] = []; + const [x0, x1] = analysis.limits; + view.surface.filter((p) => p[0] >= x0 && p[0] <= x1).forEach((p) => pts.push(p)); + analysis.critical?.points.forEach((p) => pts.push(p)); + if (analysis.grid) { + const g = analysis.grid; + pts.push([Math.min(...g.x), Math.min(...g.z)], [Math.max(...g.x), Math.max(...g.z)]); + } + if (!pts.length) return { scale: 1, tx: 0, ty: 0 }; + const scr = pts.map((p) => toScreen(fit, p)); + const [sx0, sx1] = [Math.min(...scr.map((p) => p[0])), Math.max(...scr.map((p) => p[0]))]; + const [sy0, sy1] = [Math.min(...scr.map((p) => p[1])), Math.max(...scr.map((p) => p[1]))]; + const raw = Math.min( + width / Math.max((sx1 - sx0) * 1.25, 1), + height / Math.max((sy1 - sy0) * 1.25, 1), + ); + const scale = Math.min(Math.max(raw, 1), maxScale); + return { + scale, + tx: width / 2 - (scale * (sx0 + sx1)) / 2, + ty: height / 2 - (scale * (sy0 + sy1)) / 2, + }; +} + /** 결과 좌표 → 화면 px */ export function toScreen(fit: ViewFit, p: Point): Point { return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; @@ -336,9 +373,9 @@ export function forceArrows( return [ arrow("W", slice.weight, centroid, [0, -1]), arrow("Q", slice.surcharge, midTop, [0, -1], true), - arrow("N", slice.N_eff, midBase, n), - arrow("U", slice.U, midBase, n), - arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]]), + arrow("N", slice.N_eff, midBase, n, true), + arrow("U", slice.U, midBase, n, true), + arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]], true), arrow("EL", slice.E_left, nearFace, [s, 0], true), arrow("ER", slice.E_right, farFace, [-s, 0], true), arrow("XL", slice.X_left, nearFace, [0, 1], true), diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 40f2fae3c..6e88d4d9b 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -50,6 +50,8 @@ export const ui_locales_e03 = { E03_Error: ["오류", "Error"], E03_Cut: ["깎기", "Cut"], E03_Fill: ["쌓기", "Fill"], + E03_CutShort: ["깎", "C"], + E03_FillShort: ["쌓", "F"], E03_Left: ["좌 비탈", "Left slope"], E03_Right: ["우 비탈", "Right slope"], E03_Side: ["비탈", "Slope"], From f2c3aefae23dc0b67ff046801bed3f6777d95993 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:05:14 +0900 Subject: [PATCH 05/34] =?UTF-8?q?fix(532-1):=20E01=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=9D=BD=EA=B8=B0=20=E2=80=94=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20null=20=C2=B7=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C=20=C2=B7=20=EC=98=9B=20=EA=BC=B4=EB=8F=84=20?= =?UTF-8?q?B06=20=EC=9D=B4=EB=A6=84=20=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=B0=BE=EC=9D=8C=20=C2=B7=20=EC=97=86=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EA=B7=B8=20=EC=B8=A1=EC=A0=90=EB=A7=8C=20=EA=B1=B4=EB=84=88?= =?UTF-8?q?=EB=9C=80=20=C2=B7=20=EC=83=9D=EC=84=B1=20=EB=B3=B5=EC=82=AC=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0(=EC=9A=94=EA=B5=AC=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Source.stations · cross_section: 경로 칸 null · 파일 없음 · 절대 경로 · 옛 정수 m 이름이면 find_cross_file 로 찾음 · 그래도 없으면 「횡단 없음」(stations 에서 빠짐 · cross_section KeyError) · missing_stations 추가 - copy_stability: 같은 검사 → manifest missing_cross_chainages · warnings · E01 생성 응답 warnings - 실측: 검증 프로젝트 14 복사 모두 stations 성공 · 검증_브레인232 null 1 행(359 m) 살림 Co-Authored-By: Claude Opus 5.5 --- E01_StabRegister/E01_StabRegister_Router.py | 10 +- E01_StabRegister/E01_StabRegister_Source.py | 42 +++++-- common_util/common_util_project_copy.py | 22 ++++ resources/tester/test_532_1_source_robust.py | 121 +++++++++++++++++++ 4 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_532_1_source_robust.py diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py index a8f553d30..c356b8d75 100644 --- a/E01_StabRegister/E01_StabRegister_Router.py +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -72,8 +72,8 @@ async def post_stab_project( request: Request, payload: CreateStabRequest, session: dict[str, Any] = Depends(require_company), -) -> dict[str, str]: - """안정성평가 프로젝트 생성 — 원본 B06 미완료면 400.""" +) -> dict[str, Any]: + """안정성평가 프로젝트 생성 — 원본 B06 미완료면 400 · 횡단 없는 측점은 warnings.""" company_id = session.get("company_id") rows = await _sources(company_id, payload.source_project_id) if company_id else [] if not rows: @@ -102,7 +102,11 @@ async def post_stab_project( logger.exception("E01 복사 실패 — 만든 프로젝트를 지움: %s", project_id) await hard_delete_project(project_id, int(session["user_id"]), request) raise HTTPException(status_code=500, detail="원본 자료 복사에 실패했습니다.") from None - return {"project_id": project_id, "source_fingerprint": manifest["source_fingerprint"]} + return { + "project_id": project_id, + "source_fingerprint": manifest["source_fingerprint"], + "warnings": manifest.get("warnings", []), + } async def _copy_source(project_id: str, source_id: str) -> dict[str, Any]: diff --git a/E01_StabRegister/E01_StabRegister_Source.py b/E01_StabRegister/E01_StabRegister_Source.py index ba4895d65..c8e5d9261 100644 --- a/E01_StabRegister/E01_StabRegister_Source.py +++ b/E01_StabRegister/E01_StabRegister_Source.py @@ -18,6 +18,7 @@ from common_util.common_util_project_copy import ( MANIFEST_NAME, STABILITY_DB_NAME, STABILITY_SOURCE_DIR, + resolve_cross_file, ) from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -69,16 +70,25 @@ def db_snapshot(root: Path) -> dict[str, Any]: def _cross_rows(root: Path) -> list[dict[str, Any]]: - rows = db_snapshot(root)["cross_sections"] - return sorted(rows, key=lambda row: (float(row["chainage_m"] or 0.0), row["sequence_num"] or 0)) + rows = [row for row in db_snapshot(root)["cross_sections"] if row["chainage_m"] is not None] + return sorted(rows, key=lambda row: (float(row["chainage_m"]), row["sequence_num"] or 0)) + + +def _row_data(row: dict[str, Any]) -> dict[str, Any]: + data = row.get("data") + return (json.loads(data) if isinstance(data, str) else data) or {} def stations(root: Path) -> list[dict[str, Any]]: - """측점 목록(누가 차례) — chainage_m · label · 중심 좌표 · 방위각 · 오르막 쪽 · 지반 유형.""" + """측점 목록(누가 차례) — chainage_m · label · 중심 좌표 · 방위각 · 오르막 쪽 · 지반 유형. + + 횡단 파일을 못 찾은 측점(`missing_stations`)은 「횡단 없음」으로 빠짐 — 전체는 안 깨짐.""" out = [] for row in _cross_rows(root): - data = json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] - cross = _json(root / row["cross_section_file_path"]) + path = resolve_cross_file(root, row) + if path is None: + continue + cross = _json(path) out.append( { "chainage_m": float(row["chainage_m"]), @@ -89,27 +99,39 @@ def stations(root: Path) -> list[dict[str, Any]]: "center_z": cross.get("center_z"), "azimuth_deg": cross.get("azimuth_deg"), "uphill_side": cross.get("uphill_side"), - "ground_type": (data.get("design") or {}).get("ground_type"), + "ground_type": (_row_data(row).get("design") or {}).get("ground_type"), } ) return out +def missing_stations(root: Path) -> list[float]: + """횡단 없음으로 빠진 측점 누가거리 — 화면 알림용(개수 = len).""" + return [ + float(row["chainage_m"]) + for row in _cross_rows(root) + if resolve_cross_file(root, row) is None + ] + + def cross_section(root: Path, chainage_m: float) -> dict[str, Any]: """한 측점 횡단 — 지반선(samples: offset_m · x · y · z · valid) · 좌표틀 · B06 설계값 (`design`: design_line · road_edges · section_mode · 절성토 경사 · 소단 · ground_type · - rock_boundary_offset_m(암 경계 = 지반선을 그만큼 내린 선 · 암 측점만) …).""" + rock_boundary_offset_m(암 경계 = 지반선을 그만큼 내린 선 · 암 측점만) …). + 측점이 없거나 횡단 없음이면 KeyError.""" for row in _cross_rows(root): if abs(float(row["chainage_m"]) - float(chainage_m)) < 1e-6: - data = json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] - cross = _json(root / row["cross_section_file_path"]) + path = resolve_cross_file(root, row) + if path is None: + raise KeyError(f"횡단 없음: {chainage_m}") + cross = _json(path) return { "chainage_m": float(row["chainage_m"]), "label": cross.get("label"), "frame": cross.get("frame"), "uphill_side": cross.get("uphill_side"), "samples": cross.get("samples") or [], - "design": data.get("design") or {}, + "design": _row_data(row).get("design") or {}, } raise KeyError(f"측점 없음: {chainage_m}") diff --git a/common_util/common_util_project_copy.py b/common_util/common_util_project_copy.py index 12783a998..4cfe2e059 100644 --- a/common_util/common_util_project_copy.py +++ b/common_util/common_util_project_copy.py @@ -27,6 +27,7 @@ from uuid import uuid4 import aiomysql +from B05_Profile.B05_Profile_Engine_Sections_Names import find_cross_file from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params @@ -42,6 +43,19 @@ _STABILITY_TREES = ( "B06_Section/cross_sections", "B04_PreProcess/drainage/edits", ) +_CROSS_DIR = "B06_Section/cross_sections" + + +def resolve_cross_file(root: Path, row: dict[str, Any]) -> Path | None: + """횡단 행 → root 아래 횡단 파일 — 경로 칸이 없거나(null) · 파일이 없거나 · 절대 경로(옛 꼴)면 + B06 이름 규칙(`find_cross_file` · 옛 정수 m 이름 포함)으로 찾음 · 그래도 없으면 None.""" + relative = row.get("cross_section_file_path") + if relative and not Path(relative).is_absolute() and (root / relative).is_file(): + return root / relative + if row.get("chainage_m") is None: + return None + found = find_cross_file(root / _CROSS_DIR, float(row["chainage_m"])) + return found if found.is_file() else None async def _rows(cursor, sql, args): @@ -424,6 +438,11 @@ async def copy_stability(connection, source_id: str, new_id: str, project_root: digest.update(STABILITY_DB_NAME.encode("utf-8") + b"\0" + db_text.encode("utf-8")) (target / STABILITY_DB_NAME).write_text(db_text, encoding="utf-8") sizes[STABILITY_DB_NAME] = len(db_text.encode("utf-8")) + missing = sorted( + float(row["chainage_m"] or 0.0) + for row in dump["cross_sections"] + if resolve_cross_file(target, row) is None + ) manifest = { "scope": "stability", "source_project_id": source_id, @@ -437,6 +456,9 @@ async def copy_stability(connection, source_id: str, new_id: str, project_root: "longitudinal_sections": len(dump["longitudinal_sections"]), }, "uuid_replaced": replaced, + #: 횡단 파일을 못 찾은 측점(누가거리) — E 는 그 측점을 「횡단 없음」으로 건너뜀 + "missing_cross_chainages": missing, + "warnings": [f"횡단 없는 측점 {len(missing)} 개 — 해석에서 빠짐"] if missing else [], "total_bytes": sum(sizes.values()), "elapsed_s": round(time.perf_counter() - started, 2), "files": sizes, diff --git a/resources/tester/test_532_1_source_robust.py b/resources/tester/test_532_1_source_robust.py new file mode 100644 index 000000000..1e238ba99 --- /dev/null +++ b/resources/tester/test_532_1_source_robust.py @@ -0,0 +1,121 @@ +"""532-1 덧 · 요구 351 — E01 복사본 읽기가 횡단 경로 null · 파일 없음 · 옛 꼴에서도 돎. + +- 경로 null · 경로 파일 없음 → B06 이름 규칙(`find_cross_file`)으로 찾음 +- 옛 꼴 — 절대 경로(원본 쪽을 읽지 않음) · 소수 측점의 옛 정수 m 이름 · data 없음 +- 어디에도 없으면 그 측점만 빠짐(`missing_stations`) · cross_section 은 KeyError +- 생성 복사(`copy_stability`)가 같은 검사 → manifest `missing_cross_chainages` · `warnings` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from common_util import common_util_project_copy as cp # noqa: E402 +from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402 + +CROSS = "B06_Section/cross_sections" + + +def _cross(root: Path, name: str, label: str) -> None: + path = root / CROSS / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"label": label, "samples": [{"offset_m": 0}]}), encoding="utf-8") + + +def _row(chainage, path, data=..., seq=0): + design = {"design": {"ground_type": "soil"}} + return { + "chainage_m": chainage, + "sequence_num": seq, + "cross_section_file_path": path, + "data": json.dumps(design) if data is ... else data, + } + + +@pytest.fixture +def root(tmp_path): + outside = tmp_path / "original" / CROSS / "cross_00040m.json" + outside.parent.mkdir(parents=True) + outside.write_text(json.dumps({"label": "원본"}), encoding="utf-8") + copy = tmp_path / "copy" + _cross(copy, "cross_00000m.json", "정상") + _cross(copy, "cross_00020m.json", "null") + _cross(copy, "cross_00030m.json", "경로 틀림") + _cross(copy, "cross_00040m.json", "절대") + _cross(copy, "cross_00050m.json", "옛 정수") # 49.8 m 측점의 옛 이름 + rows = [ + _row(0.0, f"{CROSS}/cross_00000m.json"), + _row(20.0, None, seq=None), + _row(30.0, f"{CROSS}/cross_gone.json"), + _row(40.0, str(outside)), + _row(49.8, None, data=None), + _row(60.0, None), # 어디에도 없음 + _row(None, None), # 누가거리 없는 행 — 측점 아님 + ] + (copy / src.STABILITY_DB_NAME).write_text( + json.dumps({"cross_sections": rows}), encoding="utf-8" + ) + (copy / src.MANIFEST_NAME).write_text("{}", encoding="utf-8") + return copy + + +def test_stations_skip_only_missing(root): + got = src.stations(root) + assert [s["chainage_m"] for s in got] == [0.0, 20.0, 30.0, 40.0, 49.8] + assert [s["label"] for s in got] == ["정상", "null", "경로 틀림", "절대", "옛 정수"] + assert got[0]["ground_type"] == "soil" and got[4]["ground_type"] is None + assert src.missing_stations(root) == [60.0] + + +def test_cross_section(root): + assert src.cross_section(root, 20.0)["label"] == "null" + assert src.cross_section(root, 40.0)["label"] == "절대" # 원본 쪽 파일 아님 + assert src.cross_section(root, 49.8)["design"] == {} + with pytest.raises(KeyError, match="횡단 없음"): + src.cross_section(root, 60.0) + with pytest.raises(KeyError, match="측점 없음"): + src.cross_section(root, 70.0) + + +def test_normal_copy_has_no_missing(root): + db = json.loads((root / src.STABILITY_DB_NAME).read_text(encoding="utf-8")) + db["cross_sections"] = db["cross_sections"][:1] + (root / src.STABILITY_DB_NAME).write_text(json.dumps(db), encoding="utf-8") + assert [s["label"] for s in src.stations(root)] == ["정상"] + assert src.missing_stations(root) == [] + + +def test_copy_stability_warns(tmp_path, monkeypatch): + source = tmp_path / "source" + _cross(source, "cross_00000m.json", "a") + _cross(source, "cross_00020m.json", "b") + dump = { + "project": {"storage_path": "x"}, + "surface_params": {"source_filter": "none"}, + "route": None, + "cross_sections": [ + _row(0.0, None), + _row(20.0, f"{CROSS}/cross_00020m.json"), + _row(35.5, None), + ], + "route_points": [], + "longitudinal_sections": [], + } + + async def fake_rows(connection, source_id): + return dump + + monkeypatch.setattr(cp, "_stability_rows", fake_rows) + monkeypatch.setattr(cp, "resolve_stored_project_path", lambda _: str(source)) + manifest = asyncio.run(cp.copy_stability(None, "s" * 36, "e" * 36, tmp_path / "e")) + assert manifest["missing_cross_chainages"] == [35.5] + assert manifest["warnings"] == ["횡단 없는 측점 1 개 — 해석에서 빠짐"] + written = json.loads( + (tmp_path / "e" / cp.STABILITY_SOURCE_DIR / cp.MANIFEST_NAME).read_text("utf-8") + ) + assert written["missing_cross_chainages"] == [35.5] From e6d7b79909d704bde47767d171d2b0015cf51c90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:08:46 +0900 Subject: [PATCH 06/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20354=20=E2=80=94=20ORCA=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=C2=B7=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A1=9C=20=EC=9A=B4?= =?UTF-8?q?=EC=9A=A9(.claude/orca=5Ftab.sh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index b1976b7da..cc862134a 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -141,3 +141,7 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 너 브라우져를 반복적으로 띄우고 있다고 벌써 2번째야 + +## 요구 354 (10-25 · 사용자) + +오느카 브라우져는 벌써 기능이있거나 스크립트로 운용하는게 좋아보임 From 164024ab435b5095b2b509fb772bd5a1d67ed5d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:19:59 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat(532-5):=20E03=202D=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D=EC=97=90=20=EB=B2=BD(=EC=98=B9=EB=B2=BD=20=C2=B7=20?= =?UTF-8?q?=EC=84=9D=EC=B6=95)=20=EC=97=B0=EA=B2=B0=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=97=86=EB=8A=94=20=EC=B8=A1=EC=A0=90=20=EB=9D=A0?= =?UTF-8?q?(E02=20=C2=B7=20E03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyze2d 가 측점마다 벽(walls_by_station · 복사 지문으로 기억) · 그려지는 설계선을 넘김 · 벽 있는 측점만 지문에 벽 · 벽 없는 측점 = 종전 지문 - 측점 보기 = 같은 벽 모형 · 수위선 = E02 고른 지하수 갈래(station_groundwater) - 화면 — 「반영 전」 안내 걷음 · 벽 회색 빗금 · 결과 「벽 있음」 · 목록 벽 표시 - result2d · stab/ground 에 missing_stations 칸 덧붙임 · 화면 위 띠(펼침 목록) - E03 지반 조건 = E02 화면과 같은 upgrade(옛 정본 지하수 칸 기본값) Co-Authored-By: Claude Opus 5.5 --- .../E01_StabRegister_UI_Missing.css | 21 +++++++ .../E01_StabRegister_UI_Missing.ts | 24 +++++++ E02_StabGround/E02_StabGround_Router.py | 10 ++- E02_StabGround/E02_StabGround_UI_Page.ts | 6 +- E03_StabSection/E03_StabSection_Router.py | 62 ++++++++++++++----- E03_StabSection/E03_StabSection_UI_List.ts | 7 ++- E03_StabSection/E03_StabSection_UI_Page.ts | 15 +++-- E03_StabSection/E03_StabSection_UI_Result.ts | 9 ++- E03_StabSection/E03_StabSection_UI_Section.ts | 35 ++++++++++- E03_StabSection/E03_StabSection_UI_Walls.css | 23 +++++++ E03_StabSection/E03_StabSection_View.py | 1 + E03_StabSection/E03_StabSection_Worker.py | 4 +- .../logic/E03_StabSection_Logic.ts | 2 + ui_template/ui_template_locale_e.ts | 6 ++ ui_template/ui_template_locale_e03.ts | 7 ++- 15 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 E01_StabRegister/E01_StabRegister_UI_Missing.css create mode 100644 E01_StabRegister/E01_StabRegister_UI_Missing.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Walls.css diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.css b/E01_StabRegister/E01_StabRegister_UI_Missing.css new file mode 100644 index 000000000..0e54a15cf --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.css @@ -0,0 +1,21 @@ +/* 빠진 횡단 띠 — E02 · E03 화면 위(E01_StabRegister_UI_Missing.ts) */ +.e-missing { + margin: 0 0 8px; + padding: 6px 10px; + border: 1px solid var(--color-warning); + border-left-width: 4px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: 13px; +} + +.e-missing > summary { + cursor: pointer; + font-weight: 600; +} + +.e-missing > p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.ts b/E01_StabRegister/E01_StabRegister_UI_Missing.ts new file mode 100644 index 000000000..252d0ccc9 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.ts @@ -0,0 +1,24 @@ +/* ============================================================================= + * E01_StabRegister_UI_Missing.ts + * 빠진 횡단 띠(요구 351 · PLAN 532-5) — E02 · E03 화면 위 「횡단 없는 측점 N 개 — 해석에서 빠짐」. + * 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 누르면 펼침. + * ========================================================================== */ + +import "./E01_StabRegister_UI_Missing.css"; +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; + +/** 빠진 측점이 없으면 빈 칸(띠 없음) */ +export function buildMissingBanner(missing: number[] | undefined): HTMLElement { + const list = missing ?? []; + if (!list.length) return el("div"); + const box = el("details", { + className: "e-missing", + attrs: { "data-role": "missing-stations", "data-count": String(list.length) }, + }); + box.append( + el("summary", { text: t("E_Missing_Head").replace("{n}", String(list.length)) }), + el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }), + ); + return box; +} diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index 1b85d0c70..aee593430 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -1,6 +1,7 @@ """E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3). - `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지 + + 횡단 없는 측점(`missing_stations` · 화면 띠) - `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` · 측점(B06 암 경계)은 서버 값 - `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) @@ -27,7 +28,7 @@ from common_util.common_util_project_copy import MANIFEST_NAME from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M -from E01_StabRegister.E01_StabRegister_Source import db_snapshot, source_dir +from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir from E02_StabGround import E02_StabGround_Model as model router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) @@ -96,6 +97,12 @@ async def seed_initial(project_id: str) -> None: ensure_initial(root) +def _missing(project_root: Path) -> list[float]: + """횡단 없어 해석에서 빠진 측점 — 복사본이 없으면 빈 목록.""" + root = source_dir(project_root) + return missing_stations(root) if (root / MANIFEST_NAME).is_file() else [] + + def _read_working(project_root: Path) -> dict[str, Any] | None: path = model.ground_path(project_root) return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None @@ -119,6 +126,7 @@ async def get_ground( "saved": working is not None, "defaults": defaults, "editable": _editable(session, project), + "missing_stations": _missing(root), } diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index e0e985ef4..b45bb974f 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -20,6 +20,7 @@ import { import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildCards, type E02View } from "./E02_StabGround_UI_Tables"; import { checkGround, @@ -33,6 +34,7 @@ interface GroundResponse { saved?: boolean; defaults?: GroundDefaults; editable?: boolean; + missing_stations?: number[]; } const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; @@ -95,6 +97,7 @@ export function renderE02StabGround(root: HTMLElement): void { let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; + let missing: number[] = []; const paintStatus = (errors: CheckError[]): void => { const lines: string[] = []; @@ -127,7 +130,7 @@ export function renderE02StabGround(root: HTMLElement): void { if (!view) return; const cards = buildCards(view); refreshStations = cards.refreshStations; - main.replaceChildren(cards.root); + main.replaceChildren(buildMissingBanner(missing), cards.root); mark(); }; @@ -221,6 +224,7 @@ export function renderE02StabGround(root: HTMLElement): void { void (async () => { try { const body = await request(`/projects/${projectId}/stab/ground`); + missing = body.missing_stations ?? []; view = { ground: body.ground, defaults: body.defaults as GroundDefaults, diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 7e8b3c17b..82ef4bea5 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -2,7 +2,7 @@ - `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈 - `GET …/stab/progress` — 진행률 -- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면) +- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수) + 횡단 없는 측점 - `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자 - `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json` - `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용) @@ -10,8 +10,8 @@ 입력 = E01 복사본(측점 · 횡단 · DTM) + E02 지반 조건(작업본 · 없으면 초기값) 계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab2d_station/<측점 지문>` · `_derived/stab2d_summary/<전체 지문>` — -측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 그 측점 지층. 공유 회사(보기만)는 -`require_project_access` 가 POST · PUT 를 막음. +측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 지하수 갈래 · 그 측점 지층 · 벽. +공유 회사(보기만)는 `require_project_access` 가 POST · PUT 를 막음. """ from __future__ import annotations @@ -49,6 +49,7 @@ from E01_StabRegister import E01_StabRegister_Source as src from E02_StabGround import E02_StabGround_Model as ground_model from E02_StabGround import E02_StabGround_Router as e02 from E03_StabSection import E03_StabSection_View as view +from E03_StabSection import E03_StabSection_Walls as walls_mod from E03_StabSection import E03_StabSection_Worker as worker from E03_StabSection.E03_StabSection_Engine_Model import ( ground_from_cross, @@ -94,8 +95,9 @@ def engine_hash() -> str: def _ground(root: Path) -> dict[str, Any]: - """E02 지반 조건 — 작업본 · 없으면 초기값.""" - return e02._read_working(root) or e02.ensure_initial(root) + """E02 지반 조건 — 작업본 · 없으면 초기값(옛 정본 = 빠진 지하수 칸 기본값 · E02 화면과 같음).""" + ground = e02._read_working(root) or e02.ensure_initial(root) + return ground_model.upgrade(ground, ground_model.load_defaults()) def _layers(resolved: dict[str, Any] | None) -> list | None: @@ -109,6 +111,12 @@ def _layers(resolved: dict[str, Any] | None) -> list | None: return [[resolved["upper"], None]] +@lru_cache(maxsize=4) +def _walls(source: str, _copy_fp: str) -> dict[str, dict[str, Any]]: + """측점별 벽(532-4 · Node 한 번 약 2 s) — 복사본은 동결이라 복사 지문으로 기억.""" + return walls_mod.walls_by_station(source) + + def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: """측점마다 입력 · 지문 + 전체 지문(판정 기준 포함) — 복사본이 없으면 400.""" try: @@ -126,10 +134,16 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: "road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0), } resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + try: + found = _walls(str(source), copy_fp) + except RuntimeError as error: + raise HTTPException(status_code=500, detail=str(error)) from error items = [] for station in stations: key = chainage_key(station["chainage_m"]) layers = _layers(resolved.get(key)) + entry = found.get(key) + parts = [engine_hash(), copy_fp, shared, key, layers] + ([entry] if entry else []) items.append( { "key": key, @@ -138,7 +152,8 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: "ground_type": station.get("ground_type"), "center": [station.get("center_x"), station.get("center_y")], "layers": layers, - "fp": derived_key(engine_hash(), copy_fp, shared, key, layers), + "walls": entry, + "fp": derived_key(*parts), # 벽 없는 측점 = 종전 지문 꼴 } ) factors = ground.get("safety_factors") or {} @@ -153,7 +168,12 @@ def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str ground = widen_ground(cross, sampler) if sampler else ground_from_cross(cross) except (KeyError, TypeError, ValueError): ground = ground_from_cross(cross) - return {"cross": cross, "design": cross["design"], "ground": [list(p) for p in ground]} + return { + "cross": cross, + "design": walls_mod.design_with_walls(cross["design"], item.get("walls")), + "walls": walls_mod.walls_of(item.get("walls")), + "ground": [list(p) for p in ground], + } def _sampler(source: Path) -> Any: @@ -166,7 +186,12 @@ def _sampler(source: Path) -> Any: def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]: keys = ("key", "chainage_m", "label", "ground_type", "center", "fp") - return {**{k: item[k] for k in keys}, "section_mode": design.get("section_mode")} + walls = len(walls_mod.walls_of(item.get("walls")) or []) + return { + **{k: item[k] for k in keys}, + "section_mode": design.get("section_mode"), + "walls": walls, + } def _run_worker(job: dict[str, Any], emit: Any) -> None: @@ -323,8 +348,9 @@ async def result2d( summary, fresh = _latest_summary(root, plan["fp"]) editable = e02._editable(session, project) running = bool((_JOBS.get(str(project_id)) or {}).get("running")) + missing = src.missing_stations(plan["source"]) # 횡단 없어 해석에서 빠진 측점(화면 띠) if not summary: - rows = [view.station_row(item, None, plan["factors"]) for item in plan["items"]] + rows = [view.station_row(_meta(item, {}), None, plan["factors"]) for item in plan["items"]] view.mark_representative(rows, _overrides(root)) return { "status": "none", @@ -334,6 +360,7 @@ async def result2d( "conditions": plan["shared"]["conditions"], "factors": plan["factors"], "stations": rows, + "missing_stations": missing, } rows = summary["stations"] view.mark_representative(rows, _overrides(root)) @@ -344,6 +371,7 @@ async def result2d( "running": running, **{k: v for k, v in summary.items() if k != "stations"}, "stations": rows, + "missing_stations": missing, } @@ -360,18 +388,22 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]: raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.") value = saved["value"] cross = src.cross_section(plan["source"], row["chainage_m"]) + entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None) + design = walls_mod.design_with_walls(cross["design"], entry) kw = { "materials": plan["shared"]["materials"], "layers": value["inputs"]["layers"], "ground": [tuple(p) for p in value["inputs"]["ground"]], "road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"], + "walls": walls_mod.walls_of(entry), } - dry = section_model_from_b06(cross, cross["design"], **kw) - wet = section_model_from_b06(cross, cross["design"], wet=True, **kw) - water = { - cond: (wet["groundwater"].get("line") if cond == "wet" else None) - for cond in summary["conditions"] - } + dry = section_model_from_b06(cross, design, **kw) + water = {} + for cond in summary["conditions"]: + # 수위선 = E02 고른 갈래(r_u · 없음 = 선 없음) + model = section_model_from_b06(cross, design, wet=cond == "wet", **kw) + gw = ground_model.station_groundwater(ground, cond, model) + water[cond] = gw.get("line") if gw.get("mode") == "line" else None out = view.section_view(row, value["out"], dry, water) out["fresh"] = fresh return out diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 4a88a343e..6a810878a 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -82,7 +82,12 @@ export function buildList( onToggle(row.key); }); tr.append( - el("td", { text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key }), + el("td", { + text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + children: row.walls + ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] + : [], + }), el("td", { text: row.sides.length ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index 53bc15939..bd1c3c1b1 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -20,12 +20,14 @@ import { } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; import { buildPlan } from "./E03_StabSection_UI_Plan"; import { buildResult } from "./E03_StabSection_UI_Result"; import { buildLegends, createSectionCanvas, + WALL_ID, type SectionToggles, } from "./E03_StabSection_UI_Section"; import { openView3d } from "./E03_StabSection_UI_View3D"; @@ -46,6 +48,8 @@ interface Result2d { computed_at?: string; summary?: Record; stations: StationRow[]; + /** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */ + missing_stations?: number[]; } interface Progress { @@ -156,6 +160,7 @@ export function renderE03StabSection(root: HTMLElement): void { panel.append(controls, ngBox, status, listHost, actions); // ---- 본문 ---- + const missingHost = el("div"); const planHost = el("div"); const toolbar = el("div", { className: "e03-card__head" }); const sectionCard = el("div", { className: "e03-card" }); @@ -168,7 +173,7 @@ export function renderE03StabSection(root: HTMLElement): void { const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } }); const body = el("div", { className: "e03-body" }); body.append(sectionCard, resultHost); - main.append(planHost, body); + main.append(missingHost, planHost, body); const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { const server = row.representative ?? { auto: [], user: null, on: false }; @@ -290,10 +295,9 @@ export function renderE03StabSection(root: HTMLElement): void { toolbar.append(title, sideField.root, flags); const draw = { view, analysis, toggles, picked }; canvas.render(draw); - legendHost.append( - buildLegends(draw), - el("p", { className: "e03-muted", text: t("E03_NoWalls") }), - ); + legendHost.append(buildLegends(draw)); + if (view.regions.some((r) => r.material_id === WALL_ID)) + legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); resultHost.append( buildResult(view, analysis, picked, (i) => { picked = i; @@ -335,6 +339,7 @@ export function renderE03StabSection(root: HTMLElement): void { result = await api(`${base}/result2d`); if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로 sections.clear(); + missingHost.replaceChildren(buildMissingBanner(result.missing_stations)); const conds = result.conditions; if (!conds.includes(condition)) condition = conds[0] ?? "dry"; condField.setOptions( diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts index 18f41337a..d8b7fb5d0 100644 --- a/E03_StabSection/E03_StabSection_UI_Result.ts +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -17,7 +17,7 @@ import { type Point, type SectionView, } from "./logic/E03_StabSection_Logic"; -import { materialClass, svgEl } from "./E03_StabSection_UI_Section"; +import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section"; const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); @@ -32,7 +32,7 @@ export function verdictChip(ok: boolean | null): HTMLElement { }); } -function kpi(analysis: Analysis): HTMLElement { +function kpi(analysis: Analysis, walls: number): HTMLElement { const c = analysis.critical; const dl = el("dl", { className: "e03-kpi" }); const row = (label: string, value: HTMLElement | string, big = false): void => { @@ -54,6 +54,8 @@ function kpi(analysis: Analysis): HTMLElement { : "-", ); row(t("E03_Height"), `${fmt(analysis.height_m)} m`); + if (walls) + row(t("E03_Walls"), el("span", { text: String(walls), attrs: { "data-role": "walls" } })); if (c?.center) { row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`); row(t("E03_Radius"), `${fmt(c.radius)} m`); @@ -284,7 +286,8 @@ export function buildResult( } const head = (key: LocaleKey): HTMLElement => el("h3", { className: "e03-card__title", text: t(key) }); - root.append(head("E03_Result"), kpi(analysis), methodTable(analysis)); + const walls = view.regions.filter((r) => r.material_id === WALL_ID).length; + root.append(head("E03_Result"), kpi(analysis, walls), methodTable(analysis)); root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick)); root.append(head("E03_Fbd"), freeBody(view, analysis, picked)); root.append(head("E03_Graph"), stressGraph(analysis, picked)); diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index 273d9f866..7a50b1f15 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -1,11 +1,12 @@ /* ============================================================================= * E03_StabSection_UI_Section.ts - * E03 가운데 횡단(PLAN 532-3) — 지층 색 · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자 + * E03 가운데 횡단(PLAN 532-3) — 지층 색 · 벽(회색 빗금 · 532-5) · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자 * 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름). * 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic. * 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율). * ========================================================================== */ +import "./E03_StabSection_UI_Walls.css"; import { t } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; @@ -27,6 +28,8 @@ import { const SVG_NS = "http://www.w3.org/2000/svg"; const PAD = 28; const MAX_SCALE = 30; +/** 벽(옹벽 · 석축) 물성 id — 서버 `WALL_MATERIAL` */ +export const WALL_ID = "wall"; export function svgEl( tag: K, @@ -87,7 +90,9 @@ export function buildLegends(draw: SectionDraw): HTMLElement { for (const m of draw.view.materials.filter((x) => used.has(x.id))) { const item = el("span", { className: "e03-legend__item" }); item.append( - el("span", { className: `e03-legend__swatch ${materialClass(draw.view, m.id)}` }), + el("span", { + className: `e03-legend__swatch ${m.id === WALL_ID ? "e03-wall-swatch" : materialClass(draw.view, m.id)}`, + }), m.name, ); box.append(item); @@ -121,10 +126,34 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec const ns = { "vector-effect": "non-scaling-stroke" }; const unit = 1 / fit.k; // 1 px 의 결과 좌표 길이 + // 벽 빗금 — 결과 좌표 패턴(화면 6 px 간격) + const hatch = svgEl("pattern", { + id: "e03-wall-hatch", + patternUnits: "userSpaceOnUse", + width: 6 * unit, + height: 6 * unit, + patternTransform: "rotate(45)", + }); + hatch.append( + svgEl("rect", { width: 6 * unit, height: 6 * unit, class: "e03-wall-hatch__bg" }), + svgEl("line", { + x1: 0, + y1: 0, + x2: 0, + y2: 6 * unit, + "stroke-width": 1.5 * unit, + class: "e03-wall-hatch__line", + }), + ); + const defs = svgEl("defs"); + defs.append(hatch); + world.append(defs); for (const region of view.regions) { + const wall = region.material_id === WALL_ID; const ring = svgEl("polygon", { points: pointsText(region.polygon), - class: `e03-region ${materialClass(view, region.material_id)}`, + class: wall ? "e03-wall" : `e03-region ${materialClass(view, region.material_id)}`, + ...(wall ? { fill: "url(#e03-wall-hatch)", "data-role": "wall" } : {}), ...ns, }); world.append(ring); diff --git a/E03_StabSection/E03_StabSection_UI_Walls.css b/E03_StabSection/E03_StabSection_UI_Walls.css new file mode 100644 index 000000000..13c7771c5 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Walls.css @@ -0,0 +1,23 @@ +/* E03 횡단 벽(옹벽 · 석축) 회색 빗금 · 목록 표시(532-5) */ +.e03-wall { + stroke: var(--e03-surface-line); + stroke-width: 1; +} + +.e03-wall-hatch__bg { + fill: #9a9a9a; + fill-opacity: 0.35; +} + +.e03-wall-hatch__line { + stroke: #6b6b6b; +} + +.e03-wall-swatch { + background: repeating-linear-gradient(45deg, #6b6b6b 0 2px, #c8c8c8 2px 5px); +} + +.e03-wall-tag { + margin-left: 4px; + color: var(--color-text-secondary); +} diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py index a52d20470..fcefcfbcb 100644 --- a/E03_StabSection/E03_StabSection_View.py +++ b/E03_StabSection/E03_StabSection_View.py @@ -76,6 +76,7 @@ def station_row( "ground_type": meta.get("ground_type"), "center": meta.get("center"), "fp": meta.get("fp"), + "walls": meta.get("walls") or 0, "sides": list(sides.values()), "cases": cases, "min": minimum, diff --git a/E03_StabSection/E03_StabSection_Worker.py b/E03_StabSection/E03_StabSection_Worker.py index 9dcc4b2d4..2e661d97b 100644 --- a/E03_StabSection/E03_StabSection_Worker.py +++ b/E03_StabSection/E03_StabSection_Worker.py @@ -3,7 +3,7 @@ `python -m E03_StabSection.E03_StabSection_Worker ` — 서버(`Router.run_all`)가 띄움. 측점마다 프로세스로 나눠 돌리고 끝날 때마다 파생 저장본을 쓰고 한 줄(JSON)을 찍음 → 서버 진행률. 서버 프로세스에서 바로 프로세스 풀을 열면 자식마다 `main.py` 를 다시 읽어 이 모듈을 입구로 둠. -job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, ground}]}. +job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, walls, ground}]}. """ from __future__ import annotations @@ -24,6 +24,8 @@ logger = logging.getLogger(__name__) def _analyze(item: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]: kw = {**kwargs, "layers": item["layers"], "ground": [tuple(p) for p in item["ground"]]} + if item.get("walls"): + kw["walls"] = item["walls"] return analyze_station(item["cross"], item["design"], **kw) diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index 134e8ade0..f1bcda7ce 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -31,6 +31,8 @@ export interface StationRow { min: Partial>; verdict: Partial>; error: string | null; + /** 그 측점 벽(옹벽 · 석축) 수 — 옛 저장본은 없음 */ + walls?: number; representative?: { auto: string[]; user: boolean | null; on: boolean }; } diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts index eccacb836..d262b9d94 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -140,4 +140,10 @@ export const ui_locales_e = { E02_Err_empty: ["지층이 하나는 있어야 합니다", "At least one layer is required"], E02_Err_type: ["꼴이 틀립니다", "Invalid shape"], E02_Err_few_points: ["점이 둘 이상 있어야 합니다", "At least two points are required"], + + /* --- E02 · E03 빠진 횡단 띠 --- */ + E_Missing_Head: [ + "횡단 없는 측점 {n} 개 — 해석에서 빠짐", + "{n} stations without a cross-section — left out of the analysis", + ], } as const satisfies Record; diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 6e88d4d9b..0093ac164 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -73,9 +73,10 @@ export const ui_locales_e03 = { E03_Legend_Map: ["등안전율(Fs)", "Safety factor"], E03_Legend_Layers: ["지층", "Layers"], E03_Offset: ["거리", "Offset"], - E03_NoWalls: [ - "옹벽 · 석축은 아직 해석에 반영 전입니다(벽 없는 단면으로 셈).", - "Retaining walls and masonry are not yet reflected (analyzed without walls).", + E03_Walls: ["벽 있음", "Walls"], + E03_Walls_Hint: [ + "옹벽 · 석축(회색 빗금)은 강한 물체로 넣어 활동면이 지나지 않습니다.", + "Retaining walls (gray hatch) are modeled as rigid bodies — slip surfaces do not cross them.", ], /* --- 우측 결과 --- */ From 05026b04dbece87768c112ba5c9c5de281165f54 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:20:30 +0900 Subject: [PATCH 08/34] =?UTF-8?q?fix(E04):=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=EC=97=90=EC=84=9C=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20=EC=95=9E=20=C2=B7=20=EC=A2=85=EC=A0=90=20?= =?UTF-8?q?=EB=92=A4=20=EB=BA=8C=20=C2=B7=20=EC=A7=80=EB=8F=84=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=ED=91=9C=EC=8B=9C=20=3D=20=EC=9E=84=EA=B3=84=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=EC=B2=B4=20=ED=8F=89=EB=A9=B4=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=ED=8C=8C=EB=9E=80=20=EC=84=A0=20(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Run.py | 6 ++++-- E04_Stab3D/E04_Stab3D_UI_Map.ts | 19 +++++++++++++------ E04_Stab3D/E04_Stab3D_UI_Style.css | 2 +- E04_Stab3D/logic/E04_Stab3D_Mesh.ts | 16 ++++++++++++++++ resources/tester/test_533_3_e04_engine.py | 14 ++++++++++++++ resources/tester/test_533_3_e04_logic.py | 11 +++++++++++ 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 8ffc5fbb7..299ca5ca5 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -33,7 +33,7 @@ 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-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e04-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 STAGE_DIR = "E04_Stab3D" GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 @@ -129,7 +129,8 @@ def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: def _node_mask( root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float ) -> np.ndarray: - """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안.""" + """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안 + · 가장 가까운 점이 노선 양 끝점인 셀(노선 시점 앞 · 종점 뒤)은 뺌.""" points = sorted( source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] ) @@ -145,6 +146,7 @@ def _node_mask( dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) chain = line[at, 2] keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6) + keep &= (at > 0) & (at < len(line) - 1) return keep.reshape(xx.shape) diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts index 85b8d5ebe..407511ee3 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Map.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -7,7 +7,7 @@ import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; -import { cutCells } from "./logic/E04_Stab3D_Mesh"; +import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh"; import { FS_STEPS, VOLUME_STEPS, @@ -40,6 +40,8 @@ interface View { scale: number; // px / m } +const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음 + const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); const css = (name: string, fallback: string): string => @@ -61,6 +63,7 @@ export function createPlanMap(): PlanMap { let layer: MapLayer = "fs"; let image: HTMLCanvasElement | null = null; let outline: Set = new Set(); + let marker: { x: number; y: number } | null = null; let view: View = { cx: 0, cy: 0, scale: 1 }; let width = 0; let height = 0; @@ -118,7 +121,7 @@ export function createPlanMap(): PlanMap { if (line.length < 2) return; ctx.lineJoin = ctx.lineCap = "round"; ctx.strokeStyle = text; - ctx.lineWidth = 2; + ctx.lineWidth = 1.5; ctx.beginPath(); line.forEach((p, i) => i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), @@ -184,9 +187,9 @@ export function createPlanMap(): PlanMap { } function drawMarker(text: string): void { - if (!result) return; + if (!result || !marker) return; const { best } = result; - const [px, py] = toScreen(best.x, best.y); + const [px, py] = toScreen(marker.x, marker.y); ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.beginPath(); @@ -245,7 +248,7 @@ export function createPlanMap(): PlanMap { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); const text = css("--color-text", "#1f2937"); - const accent = css("--color-primary", "#6d28d9"); + const accent = RANGE_COLOR; if (result && image) { const g = result.grid; const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); @@ -335,6 +338,8 @@ export function createPlanMap(): PlanMap { return { x: rect.left + px, y: rect.top + py }; }, result: () => result, + marker: () => marker, + outline: () => [...outline], layer: () => layer, }; (window as unknown as { __e04Map?: typeof probe }).__e04Map = probe; @@ -354,7 +359,9 @@ export function createPlanMap(): PlanMap { }, setResult(next) { result = next; - outline = new Set(next ? cutCells(next.grid, next.z, next.best).keys() : []); + const cut = next ? cutCells(next.grid, next.z, next.best) : new Map(); + outline = new Set(cut.keys()); + marker = next ? footprintCenter(next.grid, cut, next.best) : null; buildImage(); fit(); draw(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index 7b0c9b7ca..f215ec07a 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -86,7 +86,7 @@ .e04-map__empty { position: absolute; - inset: auto 0 45% 0; + inset: var(--spacing-16) 0 auto 0; margin: 0; text-align: center; color: var(--color-text-muted); diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts index b191f4e79..66361007a 100644 --- a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -90,6 +90,22 @@ export function cutCells( return out; } +/** 임계 활동체 평면 중심 = 잘린 셀 중심의 평균(없으면 구 중심) — 지도 최소 표시 자리 */ +export function footprintCenter( + grid: Grid, + cut: ReadonlyMap, + best: SlipBest, +): { x: number; y: number } { + if (!cut.size) return { x: best.x, y: best.y }; + let sx = 0; + let sy = 0; + for (const index of cut.keys()) { + sx += grid.x0 + (index % grid.nx) * grid.dx; + sy += grid.y0 + Math.floor(index / grid.nx) * grid.dx; + } + return { x: sx / cut.size, y: sy / cut.size }; +} + /** 잘린 셀의 8 이웃 중 잘리지 않은 셀(값 있음) — 활동면 테두리 */ export function rimCells( grid: Grid, diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index 9e3497618..a3549dc85 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -178,3 +178,17 @@ def test_hooks_do_not_change_search(): with pytest.raises(InterruptedError): search(strata, SearchParams(**base, should_stop=lambda: True)) assert math.isfinite(plain.best["governing"]) + + +def test_node_mask_section_and_route_ends(root): + """노드 자리 = 구간 누가 안 · 탐색 폭 안 · 노선 시점 앞(x < 0) · 종점 뒤(x > 100)는 뺌.""" + x = np.arange(-30.0, 131.0, 1.0) + y = np.arange(-20.0, 21.0, 1.0) + grid = {"x": x, "y": y} + mask = run._node_mask(root, grid, 0.0, 100.0, 12.0) + xx, yy = np.meshgrid(x, y) + assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any() + assert not mask[np.abs(yy) > 12.0].any() + assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all() + part = run._node_mask(root, grid, 20.0, 60.0, 12.0) + assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all() diff --git a/resources/tester/test_533_3_e04_logic.py b/resources/tester/test_533_3_e04_logic.py index 380ba706a..b26ff6e6a 100644 --- a/resources/tester/test_533_3_e04_logic.py +++ b/resources/tester/test_533_3_e04_logic.py @@ -60,6 +60,8 @@ writeFileSync(process.argv[3], JSON.stringify({ sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)), ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)), cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()), + center: M.footprintCenter(c.grid, M.cutCells(c.grid, c.z, c.sphere), c.sphere), + centerEmpty: M.footprintCenter(c.grid, new Map(), c.sphere), floored: Object.fromEntries(M.cutCells(c.grid, c.z, { ...c.sphere, floor: [[7 * 21 + 10, 53.0], [7 * 21 + 11, 60.0]] }).entries()), holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))), @@ -180,6 +182,15 @@ def test_slip_mesh_vertices(ts, name): assert all(z < Z[i] for i, z in cut.items()) +def test_footprint_center(ts): + """지도 최소 표시 = 잘린 셀 중심 평균 · 잘린 셀 없으면 구 중심.""" + cut = [i for i, _ in _py_cut(SPHERE)] + x = sum(GRID["x0"] + i % GRID["nx"] for i in cut) / len(cut) + y = sum(GRID["y0"] + i // GRID["nx"] for i in cut) / len(cut) + assert ts["center"] == pytest.approx({"x": x, "y": y}) + assert ts["centerEmpty"] == {"x": SPHERE["x"], "y": SPHERE["y"]} + + def test_floor_truncates_slip(ts): """못 뚫는 층 — 활동면이 층 윗면 아래로 안 내려감 · 층이 지표보다 높으면 잘린 셀 아님.""" center = 7 * 21 + 10 From d6c69364654794d4fb661f2067d617c1f261d73f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:21:18 +0900 Subject: [PATCH 09/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20355=20=E2=80=94=20=EC=82=AC=EC=9A=A9=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=EB=92=A4=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EA=B9=A8?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index cc862134a..247040c17 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -145,3 +145,7 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 ## 요구 354 (10-25 · 사용자) 오느카 브라우져는 벌써 기능이있거나 스크립트로 운용하는게 좋아보임 + +## 요구 355 (10-25 · 사용자) + +클로드 5시간 토큰 사용 제한에 걸릴것으로 예상됨. 토큰 풀린 후 바로 너를 깨워주는 스크립트가 있는지 확인. From eb8f57dcb18734a5bbd6d454aa62262acb376739 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:21:57 +0900 Subject: [PATCH 10/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20355=20=EB=8D=A7=20=E2=80=94=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B9=A8=EC=9B=80=201=20=EC=8B=9C=EA=B0=84=EC=97=90=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index 247040c17..5627df534 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -149,3 +149,5 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 ## 요구 355 (10-25 · 사용자) 클로드 5시간 토큰 사용 제한에 걸릴것으로 예상됨. 토큰 풀린 후 바로 너를 깨워주는 스크립트가 있는지 확인. + +1시간에 한번이면 됨 From c89898d00a05c9fc0bfdd8ae10b6e8850253ae8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:24:02 +0900 Subject: [PATCH 11/34] =?UTF-8?q?fix(E04):=20=EC=A4=91=EC=8B=AC=EC=84=A0?= =?UTF-8?q?=20=3D=20=EC=B8=A1=EC=A0=90=20=EC=A4=91=EC=8B=AC(B06=20?= =?UTF-8?q?=EB=88=84=EA=B0=80)=20=E2=80=94=20route=5Fpoints=20=EB=8A=94=20?= =?UTF-8?q?=EC=A0=91=EC=84=A0=20=EA=BA=BE=EC=9D=80=EC=84=A0=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=20=EB=88=84=EA=B0=80=EA=B0=80=20=EB=8B=AC=EB=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EB=85=B8=EB=93=9C=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EA=B5=AC=EA=B0=84=EC=84=A0=EC=9D=B4=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=A8=20(533-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E04_Stab3D/E04_Stab3D_Router.py | 6 +++--- E04_Stab3D/E04_Stab3D_Run.py | 19 ++++++++++++------- resources/tester/test_533_3_e04_engine.py | 14 ++------------ resources/tester/test_533_3_e04_router.py | 15 --------------- 4 files changed, 17 insertions(+), 37 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index a52acc53d..2416a95e8 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -184,7 +184,6 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]: for item in saved: current = run.fingerprint(finger_source, ground, item.pop("settings")) item["stale"] = item.pop("fingerprint") != current - points = source.db_snapshot(root).get("route_points") or [] return { "sections": run.auto_sections(stations), "stations": [ @@ -196,9 +195,10 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]: } for s in stations ], + # 중심선 = 측점 중심(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름) "line": [ - [float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])] - for p in sorted(points, key=lambda p: p["sequence_num"]) + [float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] + for s in sorted(stations, key=lambda s: float(s["chainage_m"])) ], "saved": saved, "defaults": SearchSettings().model_dump(), diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index 299ca5ca5..599886ce9 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -33,7 +33,7 @@ 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-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e04-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 STAGE_DIR = "E04_Stab3D" GROUND_FILE = Path("E02_StabGround") / "ground.json" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 @@ -127,14 +127,19 @@ def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: def _node_mask( - root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float + stations: list[dict[str, Any]], + grid: dict[str, Any], + start_m: float, + end_m: float, + half_width_m: float, ) -> np.ndarray: """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안 - · 가장 가까운 점이 노선 양 끝점인 셀(노선 시점 앞 · 종점 뒤)은 뺌.""" - points = sorted( - source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + · 가장 가까운 점이 중심선 양 끝점인 셀(시점 앞 · 종점 뒤)은 뺌. + + 중심선 = 측점 중심을 이은 선(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름).""" + line = np.array( + [[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] for s in stations] ) - line = np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) # 0.5 m 로 촘촘히 — 꺾인 점 사이 셀도 바른 누가 dense = [line[:1]] for a, b in zip(line[:-1], line[1:]): @@ -199,7 +204,7 @@ def run_engine( strata = grid_engine.strata_from_grid( grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None ) - mask = _node_mask(root, grid, settings["start_m"], settings["end_m"], half) + mask = _node_mask(stations, grid, settings["start_m"], settings["end_m"], half) ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"])) params = search_engine.SearchParams( node_mask=mask, diff --git a/resources/tester/test_533_3_e04_engine.py b/resources/tester/test_533_3_e04_engine.py index a3549dc85..011197fd3 100644 --- a/resources/tester/test_533_3_e04_engine.py +++ b/resources/tester/test_533_3_e04_engine.py @@ -83,16 +83,6 @@ def root(tmp_path, monkeypatch): z = _ground(yy) np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "cross_section", lambda r, ch: _cross(float(ch))) - monkeypatch.setattr( - run.source, - "db_snapshot", - lambda r: { - "route_points": [ - {"model_x": ch, "model_y": 0.0, "chainage_m": ch, "sequence_num": i} - for i, ch in enumerate(CHAINAGES) - ] - }, - ) return tmp_path @@ -185,10 +175,10 @@ def test_node_mask_section_and_route_ends(root): x = np.arange(-30.0, 131.0, 1.0) y = np.arange(-20.0, 21.0, 1.0) grid = {"x": x, "y": y} - mask = run._node_mask(root, grid, 0.0, 100.0, 12.0) + mask = run._node_mask(STATIONS, grid, 0.0, 100.0, 12.0) xx, yy = np.meshgrid(x, y) assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any() assert not mask[np.abs(yy) > 12.0].any() assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all() - part = run._node_mask(root, grid, 20.0, 60.0, 12.0) + part = run._node_mask(STATIONS, grid, 20.0, 60.0, 12.0) assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all() diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index aac24b02b..0e7793b5d 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -57,21 +57,6 @@ def project(tmp_path, monkeypatch): z = 500.0 + 0.4 * (yy - 200.0) + 0.002 * (xx - 200.0) ** 2 # 노선 옆 비탈 np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool)) monkeypatch.setattr(run.source, "stations", lambda root: STATIONS) - monkeypatch.setattr( - run.source, - "db_snapshot", - lambda root: { - "route_points": [ - { - "model_x": s["center_x"], - "model_y": 200.0, - "chainage_m": s["chainage_m"], - "sequence_num": i, - } - for i, s in enumerate(STATIONS) - ] - }, - ) async def fake_root(project_id): return tmp_path From 0fd6437628a25af05d90fda8110b4ff579d0c3bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:25:33 +0900 Subject: [PATCH 12/34] =?UTF-8?q?feat(536-2):=20E07=20=EA=B4=91=EC=97=AD?= =?UTF-8?q?=20=EC=9E=90=EC=97=B0=EC=82=AC=EB=A9=B4=20=EC=9C=84=ED=97=98=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=C2=B7=20=EA=B8=B8=20=C2=B7=20E05=20?= =?UTF-8?q?=EA=B4=91=EC=97=AD=EC=9A=94=EC=95=BD=20=EC=8B=9C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진 E07_StabArea_Engine — DTM 고른 격자 · Horn 경사 · D8 집수면적(B04 웅덩이 채움 · 수신 셀 재사용) · 무한사면 Fs(포화비 m) · SINMAP 안정 지수(c · tanφ 고른 분포 닫힌 꼴) · 6 등급 · 노선 띠 · 측점표 - 길 E07_StabArea_Router — POST 해석(뒤에서 · 진행률 · 지문 같으면 cached) · GET 기본값(E02 붕적토 · 범위 위 +5 kPa · +3°) · 파생 저장본 _derived/area.json(격자 base64) - E05 보고서 s7 광역요약 — 조건 · 등급별 면적 · 위험 측점 줄 · 양식 · 자리표 덧붙임 · 해석 지문에 E07 저장본 - 시험 test_536_2 새로 · test_534_3 두 파일은 시트 7 · PDF 쪽 +1 로 고침(광역요약 시트 더함) Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Area.py | 78 +++ E05_StabReport/E05_StabReport_Build.py | 11 +- E07_StabArea/E07_StabArea_Engine.py | 283 +++++++++++ E07_StabArea/E07_StabArea_Router.py | 337 ++++++++++++ common_util/common_util_project_kind.py | 8 +- config/config_system.py | 1 + main.py | 2 + .../master_template/report/stab_report.json | 478 ++++++++++++++++++ .../report/stab_report_slots.md | 10 + resources/tester/test_534_3_e05_report.py | 81 ++- .../tester/test_534_3_stab_report_form.py | 18 +- resources/tester/test_536_2_e07_area.py | 158 ++++++ 12 files changed, 1452 insertions(+), 13 deletions(-) create mode 100644 E05_StabReport/E05_StabReport_Area.py create mode 100644 E07_StabArea/E07_StabArea_Engine.py create mode 100644 E07_StabArea/E07_StabArea_Router.py create mode 100644 resources/tester/test_536_2_e07_area.py diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py new file mode 100644 index 000000000..f62bb196e --- /dev/null +++ b/E05_StabReport/E05_StabReport_Area.py @@ -0,0 +1,78 @@ +"""E05 보고서 s7 광역요약 — E07 광역 자연사면 위험 저장본 → 시트 (PLAN 536-2). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s7. +E07 저장본이 없으면 「광역 해석 전」. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, RISK_CLASS +from E07_StabArea.E07_StabArea_Router import RESULT, load + +CLASS_NAMES = ("", "안정", "보통 안정", "준안정", "위험 하한", "위험 상한", "불안정") + + +def stamp(project_root: Path) -> list[int] | None: + """해석 지문용 — 저장본 크기 · 고친 때.""" + path = Path(project_root) / RESULT + return [path.stat().st_size, path.stat().st_mtime_ns] if path.is_file() else None + + +def read(project_root: Path) -> dict[str, Any] | None: + """저장본에서 보고서에 쓰는 것만(격자 배열 뺌).""" + doc = load(project_root) + if not doc: + return None + return {k: doc[k] for k in ("settings", "computed_at", "stations", "summary")} + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + cell = dict(sheet["칸"].get(a1) or {}) + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + sheet["칸"][a1] = cell + + +def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None: + """s7 — 조건 넷 · 등급별 면적 여섯 · 위험 측점(22 행부터 한 줄씩).""" + if not area: + _set(sheet, "B5", "광역 해석 전 — E07 광역에서 [해석] 뒤 [보고서 갱신]") + return + s, summary = area["settings"], area["summary"] + _set(sheet, "B5", f"중심선 ± {s['band_m']:g} m · 격자 {s['cell_m']:g} m") + _set( + sheet, + "B6", + f"γt {s['gamma_t']:g} · γsat {s['gamma_sat']:g} kN/m³ · c {s['c']:g}~{s['c_hi']:g} kPa · " + f"φ {s['phi']:g}~{s['phi_hi']:g}° · 뿌리 점착 {s['c_root']:g} kPa · " + f"토층 {s['soil_depth_m']:g} m", + ) + rain = ( + "지표 포화(m = 1)" + if s["scenario"] == "saturated" + else f"강우 {s['rain_mm_h']:g} mm/h · 투수계수 {s['k_m_s']:g} m/s(정상류 포화비)" + ) + _set(sheet, "B7", rain) + _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) + for k in range(1, 7): + _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) + rows = [r for r in area["stations"] if r.get("worst_class", 0) >= RISK_CLASS] + template = {col: dict(sheet["칸"].get(f"{col}22") or {}) for col in "ABCDEFG"} + if not rows: + _set(sheet, "A22", "위험 측점 없음") + for i, row in enumerate(rows): + r = 22 + i + for col, cell in template.items(): + sheet["칸"][f"{col}{r}"] = dict(cell) + ch = float(row["chainage_m"]) + _set(sheet, f"A{r}", row.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}") + _set(sheet, f"B{r}", ch) + _set(sheet, f"C{r}", float(row["fs_min"])) + _set(sheet, f"D{r}", float(row["si_min"])) + _set(sheet, f"E{r}", CLASS_NAMES[int(row["worst_class"])]) + _set(sheet, f"F{r}", float(row["risk_area_m2"])) + _set(sheet, f"G{r}", float(row["slope_max_deg"])) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index d924938cf..9717c6813 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -1,12 +1,13 @@ """E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3). 입력 = E02 지반 조건(`ground.json`) · E03 2D 결과 요약(`_derived/result2d.json`) · 측점별 저장본 · -대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E01 복사본(측점 · 횡단 · 노선). +대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E07 광역 저장본(있으면) · +E01 복사본(측점 · 횡단 · 노선). 양식 = `resources/master_template/report/stab_report.json` 칸 자리 = 같은 폴더 `stab_report_slots.md`. 몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 · -프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6) +프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 (인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). @@ -30,6 +31,7 @@ from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model from E03_StabSection.E03_StabSection_Engine_Model import final_surface from E04_Stab3D import E04_Stab3D_Run as run3d +from E05_StabReport import E05_StabReport_Area as area from E05_StabReport import E05_StabReport_Figure as figure ROOT = Path(__file__).resolve().parents[1] @@ -37,7 +39,7 @@ FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.jso STAGE = "E05_StabReport" REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") -PROGRAM_SHEETS = ("s3", "s4", "s5", "s6") +PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7") BLOCK = 28 # 대표단면 한 쪽 = 28 행 E03 = Path("E03_StabSection") RESULT2D = E03 / "_derived" / "result2d.json" @@ -128,6 +130,7 @@ def analysis_fingerprint(project_root: Path) -> str: ] if folder.is_dir() else [], + "area": area.stamp(project_root), "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), } text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) @@ -181,6 +184,7 @@ def gather(project_root: Path) -> dict[str, Any]: "representatives": reps, "ground": ground, "sections3d": sorted(sections3d, key=lambda s: s["start_m"]), + "area": area.read(project_root), "fingerprint": analysis_fingerprint(project_root), } @@ -571,6 +575,7 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st fill_stations(by["s4"], inputs) jobs = fill_sections(by["s5"], inputs) jobs.append(fill_3d(by["s6"], inputs)) + area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) return jobs diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py new file mode 100644 index 000000000..add8f867a --- /dev/null +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -0,0 +1,283 @@ +"""E07 광역 자연사면 위험 — 무한사면 안전율 · SINMAP 안정 지수 (요구 345 · 351 · PLAN 536-2). + +순수 계산(storage 모름) — D 예정노선 재해 안전성도 같은 엔진을 씀(한 벌). +근거 = 지식DB `original/예정노선/02` §1.1 · §2 ① · `original/안정성평가/04`(붕적토 범위). + + 지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D8 흐름(웅덩이 채움 · 수신 셀 = B04 배수유역 + 함수 재사용) · 단위 집수면적 a = 상류 셀 수 × cell²/cell(등고선 폭 = cell) + 포화비 m = 1(지표 포화 · 기본) | min(R·a / (T·sinβ), 1) · T = K·z(정상류 · SINMAP · SHALSTAB) + 안전율 Fs = (c + c_r + [γt z(1−m) + (γsat−γw) m z] cos²β tanφ) + / ([γt z(1−m) + γsat m z] sinβ cosβ) + 안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] 고른 분포 + Fs_min ≥ 1 → SI = Fs_min · Fs_max < 1 → SI = 0 + · 그 사이 → SI = P(Fs > 1)(두 고른 분포 합 · 닫힌 꼴) + 등급 1 안정(SI ≥ 1.5) · 2 보통 안정(≥ 1.25) · 3 준안정(≥ 1.0) · 4 위험 하한(≥ 0.5) + · 5 위험 상한(> 0) · 6 불안정(SI = 0) · 0 = 자료 없음 + +격자 꼴 = 행 0 이 남쪽(y0) · 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) — E04 래스터와 같음. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import numpy as np +from scipy.interpolate import RegularGridInterpolator +from scipy.spatial import cKDTree + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + compute_receivers, + condition_surface, +) + +GAMMA_W = 9.81 # kN/m³ +SI_EDGES = (1.5, 1.25, 1.0, 0.5) # 등급 1~4 아래 경계 · 5 = (0, 0.5) · 6 = 0 +CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended") +RISK_CLASS = 4 # 이 등급부터 위험(SI < 1) +FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞) + + +@dataclass(frozen=True) +class AreaParams: + """한 번 해석 조건 — 단위 kN/m³ · kPa · ° · m · mm/h · m/s.""" + + gamma_t: float + gamma_sat: float + c: float # 점착력 아래(결정 Fs · SI 범위 아래) + c_hi: float # SI 범위 위 + phi: float + phi_hi: float + c_root: float = 0.0 # 뿌리 점착(나지 = 0) + soil_depth_m: float = 1.5 + scenario: str = "saturated" # saturated = 지표 포화 m = 1 · rain = 정상류 강우 + rain_mm_h: float = 30.0 + k_m_s: float = 1e-4 # 포화 투수계수 + + +# --------------------------------------------------------------------------- +# 지형 +# --------------------------------------------------------------------------- + + +def resample_dem(dem: dict[str, Any], cell_m: float) -> tuple[np.ndarray, float, float]: + """DTM npz(x · y 오름 축 · z (ny, nx) · valid_mask) → 고른 격자 (z, x0, y0) · 밖 = NaN.""" + x = np.asarray(dem["x"], float) + y = np.asarray(dem["y"], float) + z = np.where(np.asarray(dem["valid_mask"], bool), np.asarray(dem["z"], float), np.nan) + nx = int(math.floor((x[-1] - x[0]) / cell_m)) + 1 + ny = int(math.floor((y[-1] - y[0]) / cell_m)) + 1 + gx = x[0] + np.arange(nx) * cell_m + gy = y[0] + np.arange(ny) * cell_m + yy, xx = np.meshgrid(gy, gx, indexing="ij") + f = RegularGridInterpolator((y, x), z, bounds_error=False, fill_value=np.nan) + return f(np.column_stack([yy.ravel(), xx.ravel()])).reshape(ny, nx), float(x[0]), float(y[0]) + + +def slope_rad(z: np.ndarray, cell_m: float) -> np.ndarray: + """Horn 3×3 경사(rad) — 이웃에 NaN 이 있거나 격자 끝이면 NaN.""" + p = np.pad(z, 1, constant_values=np.nan) + + def at(dy: int, dx: int) -> np.ndarray: + return p[1 + dy : 1 + dy + z.shape[0], 1 + dx : 1 + dx + z.shape[1]] + + gx = ((at(-1, 1) + 2 * at(0, 1) + at(1, 1)) - (at(-1, -1) + 2 * at(0, -1) + at(1, -1))) / ( + 8 * cell_m + ) + gy = ((at(1, -1) + 2 * at(1, 0) + at(1, 1)) - (at(-1, -1) + 2 * at(-1, 0) + at(-1, 1))) / ( + 8 * cell_m + ) + return np.arctan(np.hypot(gx, gy)) + + +def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: + """D8 단위 집수면적 a(m) = 자기 포함 상류 셀 수 × cell. 밖 = NaN. + + ponytail: 지형 격자 끝 밖 상류는 모름(복사 DTM = 노선 띠) — 띠 끝 셀은 a 가 작게 나옴 · + 넓은 DEM(D 예정노선 등)이면 그대로 맞음. + """ + filled, valid = condition_surface(z.astype(np.float32)) + spec = SimpleNamespace(n_rows=z.shape[0], n_cols=z.shape[1], cell_m=cell_m) + receiver, _ = compute_receivers(spec, filled, valid) + flat = filled.ravel() + order = np.flatnonzero(valid.ravel()) + order = order[np.argsort(-flat[order], kind="stable")].tolist() # 높은 데서 낮은 데로 + acc = valid.ravel().astype(float).tolist() + recv = receiver.tolist() + for i in order: + r = recv[i] + if r != i: + acc[r] += acc[i] + out = np.asarray(acc).reshape(z.shape) * cell_m + return np.where(valid, out, np.nan) + + +# --------------------------------------------------------------------------- +# 무한사면 · SINMAP +# --------------------------------------------------------------------------- + + +def wetness(beta: np.ndarray, a: np.ndarray, p: AreaParams) -> np.ndarray: + """포화비 m(0~1) — 지표 포화 = 1 · 강우 = min(R·a/(T sinβ), 1).""" + if p.scenario == "saturated": + return np.ones_like(beta) + recharge = p.rain_mm_h / 1000.0 / 3600.0 # m/s + transmissivity = p.k_m_s * p.soil_depth_m # m²/s + with np.errstate(divide="ignore", invalid="ignore"): + m = recharge * a / (transmissivity * np.sin(beta)) + return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + + +def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]: + """Fs = (c + c_r)·A + tanφ·B 의 A · B(평평한 셀 = ∞).""" + z = p.soil_depth_m + cos, sin = np.cos(beta), np.sin(beta) + normal = (p.gamma_t * z * (1 - m) + (p.gamma_sat - GAMMA_W) * m * z) * cos**2 + shear = (p.gamma_t * z * (1 - m) + p.gamma_sat * m * z) * sin * cos + flat = beta < FLAT_SLOPE_RAD + with np.errstate(divide="ignore", invalid="ignore"): + a = np.where(flat, np.inf, 1.0 / shear) + b = np.where(flat, np.inf, normal / shear) + return a, b + + +def infinite_slope_fs(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: + """결정 안전율(c · φ = 범위 아래 = 보수) — 지식DB 02 §1.1 식.""" + a, b = _terms(beta, m, p) + with np.errstate(invalid="ignore"): + return np.where( + np.isinf(a), np.inf, (p.c + p.c_root) * a + math.tan(math.radians(p.phi)) * b + ) + + +def _cdf_sum_uniform( + s: np.ndarray, a1: np.ndarray, a2: np.ndarray, b1: np.ndarray, b2: np.ndarray +) -> np.ndarray: + """P(X + Y ≤ s) · X ~ U[a1, a2] · Y ~ U[b1, b2] 독립 — 폭 0 이면 그 변수는 상수.""" + wa, wb = a2 - a1, b2 - b1 + eps = 1e-12 + + def g(u: np.ndarray) -> np.ndarray: + return np.maximum(u, 0.0) ** 2 / 2 + + with np.errstate(divide="ignore", invalid="ignore"): + both = (g(s - a1 - b1) - g(s - a2 - b1) - g(s - a1 - b2) + g(s - a2 - b2)) / (wa * wb) + only_a = (s - a1 - b1) / wa + only_b = (s - a1 - b1) / wb + step = (a1 + b1 <= s).astype(float) + out = np.where( + (wa > eps) & (wb > eps), both, np.where(wa > eps, only_a, np.where(wb > eps, only_b, step)) + ) + return np.clip(np.nan_to_num(out, nan=0.0), 0.0, 1.0) + + +def stability_index(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: + """SINMAP 안정 지수 — Fs_min ≥ 1 → Fs_min · Fs_max < 1 → 0 · 그 사이 → P(Fs > 1).""" + a, b = _terms(beta, m, p) + t1, t2 = math.tan(math.radians(p.phi)), math.tan(math.radians(max(p.phi_hi, p.phi))) + c1, c2 = p.c + p.c_root, max(p.c_hi, p.c) + p.c_root + flat = np.isinf(a) + a = np.where(flat, 0.0, a) + b = np.where(flat, 0.0, b) + lo, hi = c1 * a + t1 * b, c2 * a + t2 * b + prob = 1.0 - _cdf_sum_uniform(np.ones_like(a), c1 * a, c2 * a, t1 * b, t2 * b) + si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob)) + return np.where(flat, np.inf, si) + + +def classify(si: np.ndarray) -> np.ndarray: + """SI → 등급 1~6(0 = 자료 없음 · NaN).""" + cls = np.full(si.shape, 6, dtype=np.uint8) + cls[si > 0] = 5 + for k, edge in enumerate(reversed(SI_EDGES)): # 0.5 → 4 · 1.0 → 3 · 1.25 → 2 · 1.5 → 1 + cls[si >= edge] = 4 - k + cls[np.isnan(si)] = 0 + return cls + + +def analyze_grid(z: np.ndarray, cell_m: float, p: AreaParams) -> dict[str, np.ndarray]: + """고른 격자 지형 → {slope(rad) · a · m · fs · si · cls} · 밖 = NaN / 0.""" + beta = slope_rad(z, cell_m) + a = catchment_area(z, cell_m) if p.scenario == "rain" else np.full(z.shape, np.nan) + m = wetness(beta, a, p) + fs = infinite_slope_fs(beta, m, p) + si = stability_index(beta, m, p) + nodata = np.isnan(beta) + fs[nodata] = np.nan + si[nodata] = np.nan + m = np.where(nodata, np.nan, m) + return {"slope": beta, "a": a, "m": m, "fs": fs, "si": si, "cls": classify(si)} + + +# --------------------------------------------------------------------------- +# 노선 띠 · 측점 +# --------------------------------------------------------------------------- + + +def dense_line(line: np.ndarray, step_m: float = 0.5) -> np.ndarray: + """중심선 [x, y, 누가] 를 step 간격으로 촘촘히(꺾인 점 사이 셀도 바른 누가).""" + out = [line[:1]] + for a, b in zip(line[:-1], line[1:]): + n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / step_m))) + out.append(a + (b - a) * np.linspace(0, 1, n + 1)[1:, None]) + return np.vstack(out) + + +def band_cells( + shape: tuple[int, int], x0: float, y0: float, cell_m: float, line: np.ndarray, half_m: float +) -> tuple[np.ndarray, np.ndarray]: + """노선 띠(중심선 ± half_m) 셀 → (띠 안, 셀의 가장 가까운 중심선 누가).""" + dense = dense_line(np.asarray(line, float)) + yy, xx = np.meshgrid( + y0 + np.arange(shape[0]) * cell_m, x0 + np.arange(shape[1]) * cell_m, indexing="ij" + ) + dist, at = cKDTree(dense[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) + return (dist <= half_m).reshape(shape), dense[at, 2].reshape(shape) + + +def station_table( + chainages: list[float], + cell_chain: np.ndarray, + inside: np.ndarray, + out: dict[str, np.ndarray], + cell_m: float, +) -> list[dict[str, Any]]: + """측점마다 맡은 띠 셀(이웃 측점과 중간 누가로 나눔) — + 최소 Fs · SI · 가장 나쁜 등급 · 위험 면적.""" + chs = np.asarray(sorted(chainages), float) + if not len(chs): + return [] + mids = (chs[:-1] + chs[1:]) / 2 + owner = np.searchsorted(mids, cell_chain) + have = inside & (out["cls"] > 0) + rows = [] + for k, ch in enumerate(chs): + sel = have & (owner == k) + n = int(sel.sum()) + if not n: + rows.append({"chainage_m": float(ch), "cells": 0}) + continue + cls = out["cls"][sel] + fs = out["fs"][sel] + si = out["si"][sel] + rows.append( + { + "chainage_m": float(ch), + "cells": n, + "fs_min": round(float(np.min(fs)), 3), + "si_min": round(float(np.min(si)), 3), + "worst_class": int(cls.max()), + "risk_area_m2": round(float((cls >= RISK_CLASS).sum()) * cell_m**2, 1), + "slope_max_deg": round(float(np.degrees(np.nanmax(out["slope"][sel]))), 1), + } + ) + return rows + + +def class_areas(cls: np.ndarray, inside: np.ndarray, cell_m: float) -> dict[str, float]: + """띠 안 등급별 면적 m².""" + return { + CLASS_KEYS[k]: round(float(((cls == k) & inside).sum()) * cell_m**2, 1) for k in range(1, 7) + } diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py new file mode 100644 index 000000000..dfae623ff --- /dev/null +++ b/E07_StabArea/E07_StabArea_Router.py @@ -0,0 +1,337 @@ +"""E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2). + +- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면 + `cached` · force = 다시 +- `GET …/stab/area/progress` — 진행률 +- `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약) + +계산 자리 = ③ 서버 단독(엔진 `E07_StabArea_Engine` · 순수). 지형 = 복사본 원지형 DTM +(노선면 합성 안 함 — 노선 위 · 아래 자연사면). +파생 저장본 = `E07_StabArea/_derived/area.json`(지문이 같으면 안 셈). +권한 = main.py `protected_with_company`(지정받은 회사 = GET 만). + +격자 꼴(화면 `logic/E07_StabArea_Raster.ts` 와 같은 열쇠) — grid = {x0, y0, dx, nx, ny} +(행 0 = 남쪽) · fs · si = uint16 ×1000(65535 = 자료 없음 · 65.534 위로 자름) · +cls = uint8 등급(0 없음 · 1~6) · slope = uint16 ×10 ° · m = uint8 ×100(255 없음) +— 모두 줄 우선 · 작은 끝 base64 · 띠 밖 셀 = 자료 없음. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import time +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +import aiomysql +import numpy as np +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, model_validator + +from common_util.common_util_auth import require_company +from common_util.common_util_json import atomic_write_json +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from E01_StabRegister import E01_StabRegister_Source as source +from E02_StabGround import E02_StabGround_Model as ground_model +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-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +RESULT = Path("E07_StabArea") / "_derived" / "area.json" +DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) +RANGE_C_KPA, RANGE_PHI_DEG = ( + 5.0, + 3.0, +) # SI 범위 위 = 아래 + 이 폭(04 붕적토 범위 c 0~10 · φ 25~30 폭의 반) + +_JOBS: dict[str, dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() +_IDLE = {"running": False, "percent": 0, "message": None, "error": None} + + +class AreaRequest(BaseModel): + band_m: float = Field(100.0, ge=10.0, le=500.0) # 노선 띠 = 중심선 ± 이 폭 + cell_m: float = Field(2.0, ge=1.0, le=10.0) + material_id: str | None = None # 이름표만(값은 아래 칸) + gamma_t: float = Field(..., gt=0.0, le=30.0) + gamma_sat: float = Field(..., gt=engine.GAMMA_W, le=30.0) + c: float = Field(..., ge=0.0, le=500.0) + c_hi: float = Field(..., ge=0.0, le=500.0) + phi: float = Field(..., ge=0.0, le=60.0) + phi_hi: float = Field(..., ge=0.0, le=60.0) + c_root: float = Field(0.0, ge=0.0, le=50.0) + soil_depth_m: float = Field(1.5, ge=0.1, le=10.0) + scenario: Literal["saturated", "rain"] = "saturated" + rain_mm_h: float = Field(30.0, ge=0.0, le=300.0) + k_m_s: float = Field(1e-4, ge=1e-8, le=1e-1) + force: bool = False + + @model_validator(mode="after") + def _ranges(self) -> AreaRequest: + if self.c_hi < self.c or self.phi_hi < self.phi: + raise ValueError("범위 위가 아래보다 작습니다(c · φ).") + return self + + +# --------------------------------------------------------------------------- +# 입력 · 지문 · 저장본 +# --------------------------------------------------------------------------- + + +def _ground(project_root: Path) -> dict[str, Any]: + for path in (ground_model.ground_path(project_root), ground_model.initial_path(project_root)): + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return ground_model.pick(ground_model.load_defaults()) + + +def defaults(project_root: Path) -> dict[str, Any]: + """기본 조건 — E02 붕적토(없으면 첫 물성) · 범위 위 = + 5 kPa · + 3°.""" + materials = _ground(project_root).get("materials") or [] + mat = next((m for m in materials if m.get("id") == DEFAULT_MATERIAL), None) or materials[0] + return { + **{ + k: f.default + for k, f in AreaRequest.model_fields.items() + if not f.is_required() and k != "force" + }, + "material_id": mat.get("id"), + "gamma_t": float(mat["gamma_t"]), + "gamma_sat": float(mat["gamma_sat"]), + "c": float(mat["c"]), + "c_hi": float(mat["c"]) + RANGE_C_KPA, + "phi": float(mat["phi"]), + "phi_hi": float(mat["phi"]) + RANGE_PHI_DEG, + } + + +def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str: + body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION} + text = json.dumps(body, ensure_ascii=False, sort_keys=True) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def load(project_root: Path) -> dict[str, Any] | None: + path = Path(project_root) / RESULT + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _route_line(root: Path) -> np.ndarray: + points = sorted( + source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + ) + return np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) + + +def _b64(values: np.ndarray, dtype: str) -> str: + return base64.b64encode(np.ascontiguousarray(values, dtype=dtype).tobytes()).decode("ascii") + + +def _u16(values: np.ndarray, scale: float, keep: np.ndarray) -> str: + v = np.round(np.clip(np.nan_to_num(values * scale, nan=-1.0, posinf=65534.0), -1, 65534)) + return _b64(np.where(keep & (v >= 0), v, 65535), " dict[str, Any]: + """해석 한 번 → 저장본 쓰고 요약 돌려줌.""" + started = time.perf_counter() + cell = float(settings["cell_m"]) + report(5, "지형 격자") + with np.load(source.terrain_grid_path(root)) as npz: + dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")} + z, x0, y0 = engine.resample_dem(dem, cell) + report(25, "경사 · 집수면적 · 안전율") + keys = ( + "gamma_t", + "gamma_sat", + "c", + "c_hi", + "phi", + "phi_hi", + "c_root", + "soil_depth_m", + "scenario", + "rain_mm_h", + "k_m_s", + ) + out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys})) + report(75, "노선 띠 · 측점") + stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + inside, chain = engine.band_cells( + z.shape, x0, y0, cell, _route_line(root), float(settings["band_m"]) + ) + rows = engine.station_table([s["chainage_m"] for s in stations], chain, inside, out, cell) + labels = {round(float(s["chainage_m"]), 3): s.get("label") for s in stations} + for row in rows: + row["label"] = labels.get(round(row["chainage_m"], 3)) + # 띠 상자로 잘라 저장 + iy, ix = np.nonzero(inside) + if not len(iy): + raise ValueError("노선 띠가 지형 격자와 겹치지 않습니다.") + win = (slice(iy.min(), iy.max() + 1), slice(ix.min(), ix.max() + 1)) + keep = inside[win] & (out["cls"][win] > 0) + areas = engine.class_areas(out["cls"], inside, cell) + have = inside & (out["cls"] > 0) + summary = { + "areas_m2": areas, + "band_area_m2": round(float(inside.sum()) * cell**2, 1), + "covered_area_m2": round(float(have.sum()) * cell**2, 1), + "risk_area_m2": round( + sum(v for k, v in areas.items() if engine.CLASS_KEYS.index(k) >= engine.RISK_CLASS), 1 + ), + "fs_min": round(float(np.min(out["fs"][have])), 3) if have.any() else None, + "risk_stations": [ + r["chainage_m"] for r in rows if r.get("worst_class", 0) >= engine.RISK_CLASS + ], + "stations": len(rows), + } + doc = { + "fingerprint": finger, + "engine": ENGINE_VERSION, + "settings": settings, + "computed_at": datetime.now().isoformat(timespec="seconds"), + "elapsed_s": round(time.perf_counter() - started, 2), + "grid": { + "x0": x0 + int(ix.min()) * cell, + "y0": y0 + int(iy.min()) * cell, + "dx": cell, + "nx": int(keep.shape[1]), + "ny": int(keep.shape[0]), + }, + "fs": _u16(out["fs"][win], 1000, keep), + "si": _u16(out["si"][win], 1000, keep), + "cls": _b64(np.where(keep, out["cls"][win], 0), "u1"), + "slope": _u16(np.degrees(out["slope"][win]), 10, keep), + "m": _b64(np.where(keep, np.round(np.nan_to_num(out["m"][win], nan=0) * 100), 255), "u1"), + "stations": rows, + "summary": summary, + } + report(95, "저장") + atomic_write_json(Path(project_root) / RESULT, doc) + return summary + + +# --------------------------------------------------------------------------- +# 길 +# --------------------------------------------------------------------------- + + +async def _project_root(project_id: UUID) -> Path: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT storage_path FROM projects" + " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row["storage_path"]: + raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.") + return Path(resolve_stored_project_path(row["storage_path"])) + + +def _source_root(project_root: Path) -> Path: + root = source.source_dir(project_root) + if not (root / source.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.") + return root + + +@router.post("/{project_id}/stab/area/analyze", response_model=None) +async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]: + """[해석] — 지문이 같으면 `cached`(안 셈) · 다르거나 force 면 뒤에서 셈.""" + pid = str(project_id) + 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) + saved = await asyncio.to_thread(load, project_root) + if not payload.force and saved and saved["fingerprint"] == finger: + return {"status": "cached", "summary": saved["summary"]} + if (_JOBS.get(pid) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 광역 해석이 돌고 있습니다.") + job = {**_IDLE, "running": True, "message": "시작"} + _JOBS[pid] = job + + def report(percent: int, message: str) -> None: + job.update(percent=max(0, min(99, int(percent))), message=message) + + async def work() -> None: + try: + await asyncio.to_thread(run, project_root, root, settings, finger, report) + job.update(percent=100, message="끝") + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("E07 광역 해석 실패: project_id=%s", pid) + job["error"] = str(error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E07 area {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted"} + + +@router.get("/{project_id}/stab/area/progress", response_model=None) +async def progress(project_id: UUID) -> dict[str, Any]: + return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)} + + +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"]) + saved["stale"] = saved["fingerprint"] != current + return { + "defaults": defaults(project_root), + "materials": _ground(project_root).get("materials") or [], + "line": _route_line(root).round(3).tolist(), + "stations": [ + { + "chainage_m": s["chainage_m"], + "label": s["label"], + "x": s["center_x"], + "y": s["center_y"], + } + for s in sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + ], + "result": saved, + } + + +async def _editable(project_id: UUID, session: dict[str, Any]) -> bool: + """[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만).""" + if session.get("role") == "SYSTEM_ADMIN": + return True + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),)) + row = await cursor.fetchone() + return bool(row) and row[0] == session.get("company_id") + + +@router.get("/{project_id}/stab/area", response_model=None) +async def area( + project_id: UUID, session: dict[str, Any] = Depends(require_company) +) -> dict[str, Any]: + project_root = await _project_root(project_id) + root = _source_root(project_root) + overview = await asyncio.to_thread(_overview, project_root, root) + return {"status": "success", "editable": await _editable(project_id, session), **overview} diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index d4d4e997d..5a82d734c 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -20,7 +20,13 @@ from config.config_db import get_db_pool KIND_STAGE_DIRS = { "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), - "STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"), + "STABILITY": ( + "E02_StabGround", + "E03_StabSection", + "E04_Stab3D", + "E05_StabReport", + "E07_StabArea", + ), } diff --git a/config/config_system.py b/config/config_system.py index 2fa29bb33..efe11fac1 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -220,6 +220,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset( "E03_StabSection", "E04_Stab3D", "E05_StabReport", + "E07_StabArea", } ) diff --git a/main.py b/main.py index f9d6e072d..c3b89b238 100644 --- a/main.py +++ b/main.py @@ -83,6 +83,7 @@ from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router +from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -493,6 +494,7 @@ app.include_router(d04_plan_link_router, dependencies=protected_with_company) app.include_router(e02_stab_ground_router, dependencies=protected_with_company) app.include_router(e04_stab_3d_router, dependencies=protected_with_company) app.include_router(e05_stab_report_router, dependencies=protected_with_company) +app.include_router(e07_stab_area_router, dependencies=protected_with_company) app.include_router(e03_stab_section_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 9ded03dad..42a350b7e 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -2600,6 +2600,484 @@ } } ] + }, + { + "id": "s7", + "이름": "광역요약", + "칸": { + "A1": { + "값": "광역 자연사면 위험 요약", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "H1": { + "서식": 1 + }, + "A2": { + "값": "노선 띠 무한사면 · SINMAP 안정 지수 — 노선 위 · 아래 자연사면 표층 붕괴 위험 · 문헌 추정치", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "H2": { + "서식": 2 + }, + "A4": { + "값": "1. 해석 조건", + "서식": 3 + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "G4": { + "서식": 3 + }, + "H4": { + "서식": 3 + }, + "A5": { + "값": "노선 띠", + "서식": 4 + }, + "B5": { + "서식": 5 + }, + "C5": { + "서식": 5 + }, + "D5": { + "서식": 5 + }, + "E5": { + "서식": 5 + }, + "F5": { + "서식": 5 + }, + "G5": { + "서식": 5 + }, + "H5": { + "서식": 5 + }, + "A6": { + "값": "토층 물성", + "서식": 4 + }, + "B6": { + "서식": 5 + }, + "C6": { + "서식": 5 + }, + "D6": { + "서식": 5 + }, + "E6": { + "서식": 5 + }, + "F6": { + "서식": 5 + }, + "G6": { + "서식": 5 + }, + "H6": { + "서식": 5 + }, + "A7": { + "값": "강우 · 포화", + "서식": 4 + }, + "B7": { + "서식": 5 + }, + "C7": { + "서식": 5 + }, + "D7": { + "서식": 5 + }, + "E7": { + "서식": 5 + }, + "F7": { + "서식": 5 + }, + "G7": { + "서식": 5 + }, + "H7": { + "서식": 5 + }, + "A8": { + "값": "해석 때", + "서식": 4 + }, + "B8": { + "서식": 5 + }, + "C8": { + "서식": 5 + }, + "D8": { + "서식": 5 + }, + "E8": { + "서식": 5 + }, + "F8": { + "서식": 5 + }, + "G8": { + "서식": 5 + }, + "H8": { + "서식": 5 + }, + "A10": { + "값": "2. 등급별 면적", + "서식": 3 + }, + "B10": { + "서식": 3 + }, + "C10": { + "서식": 3 + }, + "D10": { + "서식": 3 + }, + "E10": { + "서식": 3 + }, + "F10": { + "서식": 3 + }, + "G10": { + "서식": 3 + }, + "H10": { + "서식": 3 + }, + "A11": { + "값": "등급", + "서식": 6 + }, + "B11": { + "값": "안정 지수 SI", + "서식": 6 + }, + "C11": { + "값": "면적 (m²)", + "서식": 6 + }, + "D11": { + "값": "비율 (%)", + "서식": 6 + }, + "A12": { + "값": "안정", + "서식": 7 + }, + "B12": { + "값": "SI ≥ 1.5", + "서식": 7 + }, + "C12": { + "서식": 14 + }, + "D12": { + "식": "IF(SUM(C$12:C$17)>0,C12/SUM(C$12:C$17)*100,\"\")", + "서식": 16 + }, + "A13": { + "값": "보통 안정", + "서식": 7 + }, + "B13": { + "값": "1.25 ≤ SI < 1.5", + "서식": 7 + }, + "C13": { + "서식": 14 + }, + "D13": { + "식": "IF(SUM(C$12:C$17)>0,C13/SUM(C$12:C$17)*100,\"\")", + "서식": 16 + }, + "A14": { + "값": "준안정", + "서식": 7 + }, + "B14": { + "값": "1.0 ≤ SI < 1.25", + "서식": 7 + }, + "C14": { + "서식": 14 + }, + "D14": { + "식": "IF(SUM(C$12:C$17)>0,C14/SUM(C$12:C$17)*100,\"\")", + "서식": 16 + }, + "A15": { + "값": "위험 하한", + "서식": 7 + }, + "B15": { + "값": "0.5 ≤ SI < 1.0", + "서식": 7 + }, + "C15": { + "서식": 14 + }, + "D15": { + "식": "IF(SUM(C$12:C$17)>0,C15/SUM(C$12:C$17)*100,\"\")", + "서식": 16 + }, + "A16": { + "값": "위험 상한", + "서식": 7 + }, + "B16": { + "값": "0 < SI < 0.5", + "서식": 7 + }, + "C16": { + "서식": 14 + }, + "D16": { + "식": "IF(SUM(C$12:C$17)>0,C16/SUM(C$12:C$17)*100,\"\")", + "서식": 16 + }, + "A17": { + "값": "불안정", + "서식": 7 + }, + "B17": { + "값": "SI = 0", + "서식": 7 + }, + "C17": { + "서식": 14 + }, + "D17": { + "식": "IF(SUM(C$12:C$17)>0,C17/SUM(C$12:C$17)*100,\"\")", + "서식": 16 + }, + "A18": { + "값": "합계", + "서식": 4 + }, + "B18": { + "서식": 4 + }, + "C18": { + "식": "SUM(C12:C17)", + "서식": 14 + }, + "D18": { + "서식": 16 + }, + "A20": { + "값": "3. 위험 측점 (SI < 1 셀이 있는 측점)", + "서식": 3 + }, + "B20": { + "서식": 3 + }, + "C20": { + "서식": 3 + }, + "D20": { + "서식": 3 + }, + "E20": { + "서식": 3 + }, + "F20": { + "서식": 3 + }, + "G20": { + "서식": 3 + }, + "H20": { + "서식": 3 + }, + "A21": { + "값": "측점", + "서식": 6 + }, + "B21": { + "값": "누가거리 (m)", + "서식": 6 + }, + "C21": { + "값": "최소 Fs", + "서식": 6 + }, + "D21": { + "값": "최소 SI", + "서식": 6 + }, + "E21": { + "값": "가장 나쁜 등급", + "서식": 6 + }, + "F21": { + "값": "위험 면적 (m²)", + "서식": 6 + }, + "G21": { + "값": "최대 경사 (°)", + "서식": 6 + }, + "A22": { + "서식": 7 + }, + "B22": { + "서식": 17 + }, + "C22": { + "서식": 8 + }, + "D22": { + "서식": 8 + }, + "E22": { + "서식": 7 + }, + "F22": { + "서식": 14 + }, + "G22": { + "서식": 16 + } + }, + "병합": [ + "A1:H1", + "A2:H2", + "A4:H4", + "B5:H5", + "B6:H6", + "B7:H7", + "B8:H8", + "A10:H10", + "A20:H20" + ], + "열": { + "A": { + "폭": 14 + }, + "B": { + "폭": 15 + }, + "C": { + "폭": 11 + }, + "D": { + "폭": 11 + }, + "E": { + "폭": 13 + }, + "F": { + "폭": 13 + }, + "G": { + "폭": 12 + }, + "H": { + "폭": 6 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 20 + }, + "4": { + "높이": 22 + }, + "10": { + "높이": 22 + }, + "20": { + "높이": 22 + }, + "21": { + "높이": 32 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + }, + "머리행": [ + 21, + 21 + ] + } } ], "활성": "s1" diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index 3fb11f4c7..b9bafb828 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -79,3 +79,13 @@ | 20 행부터 구간 하나 = 한 줄 | A 구간 · B 시작 측점 · C 끝 측점(서식 7) · D 3D 최소 Fs · E 2D 최소 Fs(서식 8) · **F 3D/2D 비 = 수식 `IF(E{r}>0,D{r}/E{r},"")`**(서식 8) · G 임계 체적 m³(서식 14) · H 판정(서식 7 → N.G 10 · O.K 11) | - 20 행 서식을 줄마다 복사. + +## s7 광역요약 (프로그램 몫 · 머리 줄 21 행 반복 · E07 광역 저장본 · 없으면 `B5` 에 「광역 해석 전」) + +| 칸 | 내용 | +| --- | --- | +| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 강우 · 포화 · 해석 때 글 | +| `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 | +| 22 행부터 위험 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 가장 나쁜 등급 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | + +- 22 행 서식을 줄마다 복사. 위험 측점 = 맡은 띠 셀에 SI < 1(등급 4 이상)이 있는 측점. diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index e4dfc66f6..3b0c93ede 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -203,6 +203,79 @@ def test_처음_만듦_측점수_NG_그림(project): assert saved["fingerprint"] == build.analysis_fingerprint(project) +def test_광역요약_E07_저장본(project): + """E07 저장본 없음 = 「광역 해석 전」 · 생김 = 지문 바뀜 → 갱신 뒤 등급 면적 · 위험 측점 줄.""" + _create(project) + book, meta = store.load(project) + assert _sheets(book)["광역요약"]["칸"]["B5"]["값"].startswith("광역 해석 전") + keys = ("stable", "moderate", "quasi", "lower", "upper", "defended") + area = { + "settings": { + "band_m": 100.0, + "cell_m": 2.0, + "gamma_t": 17.5, + "gamma_sat": 18.5, + "c": 5.0, + "c_hi": 10.0, + "phi": 27.0, + "phi_hi": 30.0, + "c_root": 0.0, + "soil_depth_m": 1.5, + "scenario": "saturated", + "rain_mm_h": 30.0, + "k_m_s": 1e-4, + }, + "computed_at": "2026-10-25T21:00:00", + "stations": [ + { + "chainage_m": 0.0, + "cells": 9, + "fs_min": 1.6, + "si_min": 1.6, + "worst_class": 1, + "risk_area_m2": 0.0, + "slope_max_deg": 12.0, + "label": "No.0", + }, + { + "chainage_m": 20.0, + "cells": 9, + "fs_min": 0.8, + "si_min": 0.0, + "worst_class": 6, + "risk_area_m2": 36.0, + "slope_max_deg": 41.5, + "label": "No.1", + }, + { + "chainage_m": 40.0, + "cells": 9, + "fs_min": 0.95, + "si_min": 0.7, + "worst_class": 4, + "risk_area_m2": 8.0, + "slope_max_deg": 33.0, + "label": "No.2", + }, + ], + "summary": {"areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0)))}, + } + path = project / "E07_StabArea" / "_derived" / "area.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") + assert build.analysis_fingerprint(project) != meta["fingerprint"] + store.refresh(project) + book, _ = store.load(project) + s7 = _sheets(book)["광역요약"]["칸"] + assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] + assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 + assert ( + s7["E22"]["값"] == "불안정" and s7["F23"]["값"] == 8.0 and s7["B7"]["값"].startswith("지표") + ) + vals = book["계산값"]["s7"] + assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") + + def test_갱신_사용자몫_불변_덮임(project): _create(project) book, _ = store.load(project) @@ -262,7 +335,7 @@ def test_xlsx_되읽기_pdf_쪽수(project): figs = store.figures_of(project, book) assert len(figs) == len(REPS) + 1 wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) - assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"] + assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1 assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84] assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith( @@ -278,8 +351,8 @@ def test_xlsx_되읽기_pdf_쪽수(project): texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] stab = [i for i, t in enumerate(texts) if "산정 안전율" in t] assert stab and all("산정 안전율" in texts[i] for i in stab) - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 - assert len(texts) == 3 + len(stab) + len(REPS) + 1 + # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 def test_단면_그림_SVG(): @@ -317,7 +390,7 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): got = client.get(url).json() except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") - assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 6 + assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7 book = got["book"] _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 89ac63e32..8eee5c646 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -36,7 +36,7 @@ STYLE_KEYS = { } # fmt: skip PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"} A1 = re.compile(r"^([A-Z]{1,3})(\d+)$") -NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"] +NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -148,7 +148,7 @@ def test_양식_계약(): book = _form() _contract(book) assert [s["이름"] for s in book["시트"]] == NAMES - assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6"] + assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7"] def test_양식_인쇄_쪽나눔_머리행_그림(): @@ -159,7 +159,15 @@ def test_양식_인쇄_쪽나눔_머리행_그림(): assert by["측점표"]["인쇄"]["방향"] == "landscape" and by["측점표"]["인쇄"]["머리행"] == [4, 5] assert by["대표단면"]["인쇄"]["쪽나눔"] == [BLOCK] figs = {n: len(s.get("그림", [])) for n, s in by.items()} - assert figs == {"표지": 0, "개요": 0, "입력조건": 0, "측점표": 0, "대표단면": 1, "3D요약": 1} + assert figs == { + "표지": 0, + "개요": 0, + "입력조건": 0, + "측점표": 0, + "대표단면": 1, + "3D요약": 1, + "광역요약": 0, + } # N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀) assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9 assert any(st.get("글자색") == "#C00000" and st.get("채움") for st in _form()["서식"]) @@ -222,8 +230,8 @@ def test_견본_pdf_쪽수_머리행(png): ) sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t] assert len(sections) == 2 # 단면마다 한 쪽 - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 - assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab) html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0] assert html.count(" float: + p = engine.AreaParams(**{**SOIL, **kw}) + return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0]) + + +def test_무한사면_해석해_건조_포화_점착(): + t = math.tan(math.radians(35)) / math.tan(math.radians(30)) + assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # 건조 c = 0 → tanφ / tanβ + assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # 포화 → γ' / γsat 배 + # 손계산 — c 5 · 뿌리 2 · z 1.5 · β 35° · m 0.5 + b, z = math.radians(35), 1.5 + w = 18 * z * 0.5 + 20 * z * 0.5 + n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2 + hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b)) + assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12) + assert math.isinf(_fs(0, 1.0)) # 평평 = 활동 없음 + + +def test_SINMAP_안정지수_닫힌꼴_표본(): + p = engine.AreaParams(gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0) + beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) + m = np.ones_like(beta) + si = engine.stability_index(beta, m, p) + a, b = engine._terms(beta, m, p) + rng = np.random.default_rng(1) + c = rng.uniform(0, 8, 400_000) + t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000) + for k in range(len(beta)): + lo = 0 * a[k] + math.tan(math.radians(25)) * b[k] + fs = c * a[k] + t * b[k] + if lo >= 1: + assert si[k] == pytest.approx(lo) # Fs_min ≥ 1 → SI = Fs_min + elif fs.max() < 1: + assert si[k] == 0.0 + else: + assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) + assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 + # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 + q = engine.AreaParams(**SOIL) + fs = engine.infinite_slope_fs(beta, m, q) + si0 = engine.stability_index(beta, m, q) + assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) + + +def test_SINMAP_등급_경계(): + si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan]) + assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0] + + +def test_D8_집수면적_기운평면_V골짜기(): + cell = 2.0 + ny, nx = 5, 7 + xx = np.arange(nx)[None, :] * cell + plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # 동쪽으로 기움 → 행마다 동쪽으로 흐름 + a = engine.catchment_area(plane, cell) + assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell) + yy = np.arange(9)[:, None] * cell + valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell + a = engine.catchment_area(valley, cell) + assert a[4, -1] == pytest.approx(9 * nx * cell) # 전부가 골 출구로 + assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 + + +def test_강우_포화비(): + p = engine.AreaParams(**SOIL, scenario="rain", rain_mm_h=36.0, k_m_s=1e-4, soil_depth_m=2.0) + beta = np.radians(np.array([30.0, 30.0])) + a = np.array([10.0, 1e6]) + m = engine.wetness(beta, a, p) + assert m[0] == pytest.approx(1e-5 * 10 / (2e-4 * 0.5)) # R a / (T sinβ) + assert m[1] == 1.0 + assert engine.wetness(beta, a, engine.AreaParams(**SOIL)).tolist() == [1.0, 1.0] + + +def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: + return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"]) + + +@pytest.fixture() +def project(tmp_path, monkeypatch): + """가짜 E 복사본 — 서쪽이 낮은 30° 비탈(동서 200 m · 남북 100 m) · 노선 = 남북 중심선.""" + x = np.arange(0.0, 200.5, 0.5) + y = np.arange(0.0, 100.5, 0.5) + z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0) + valid = np.ones_like(z, bool) + valid[:, x > 180] = False # 동쪽 끝 = 지형 없음 + dem = tmp_path / "dtm.npz" + np.savez( + dem, + x=x.astype(np.float32), + y=y.astype(np.float32), + z=z.astype(np.float32), + valid_mask=valid, + ) + stations = [ + {"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)} + for i, c in enumerate(range(0, 101, 20)) + ] + route = [ + {"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)} + for i, c in enumerate((0, 100)) + ] + src = router_mod.source + monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem) + monkeypatch.setattr(src, "stations", lambda _r: stations) + monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route}) + return tmp_path + + +def test_길_띠_측점표_저장본(project): + settings = {**router_mod.defaults(project), "band_m": 30.0, "cell_m": 2.0} + assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 + steps = [] + summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) + doc = router_mod.load(project) + assert steps == sorted(steps) and doc["fingerprint"] == "fp" + g = doc["grid"] + cls = _decode(doc["cls"], "u1", g) + fs = _decode(doc["fs"], " 0] + assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3) + assert summary["fs_min"] == pytest.approx(hand, abs=5e-3) + # 띠 = 중심선 ± 30 m(격자 상자도 띠만큼) · 등급 면적 합 = 덮인 면적 + assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9 + assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"]) + assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0) + rows = doc["stations"] + assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100] + assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows)) + assert summary["risk_stations"] == [0, 20, 40, 60, 80, 100] + assert sum(r["cells"] for r in rows) == (cls > 0).sum() + # 지문 = 설정에 따라 바뀜 + assert router_mod.fingerprint("s", settings) != router_mod.fingerprint( + "s", {**settings, "c": 6} + ) + json.dumps(doc) # 그대로 JSON From 392260b6ad79deb009ad8ef78a7deb9bee54afb4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:26:20 +0900 Subject: [PATCH 13/34] =?UTF-8?q?fix(534-4):=20E05=20=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=84=9C=EA=B0=80=20E03=20=EC=8B=A4=EC=A0=9C=202D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=EC=9D=84=20=EC=9D=BD=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=80=EC=83=81=20result2d.json=20=C2=B7=20results/=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20stab2d=5Fsummary(=EA=B0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=B5=9C=EA=B7=BC)=20=C2=B7=20stab2d=5Fstation=20=C2=B7=20?= =?UTF-8?q?=EB=8C=80=ED=91=9C=20=EB=8B=A8=EB=A9=B4=20=3D=20E03=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20+=20=EC=BC=AC/=EB=81=94=20=C2=B7=20=EB=B9=84?= =?UTF-8?q?=ED=83=88=20=3D=20=EA=B8=B0=EC=A4=80=20=EB=8C=80=EB=B9=84=20?= =?UTF-8?q?=EA=B0=80=EC=9E=A5=20=EB=82=AE=EC=9D=80=20=EC=AA=BD=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EA=BC=B4=EB=8F=84=20E03=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전 흐름 확인에서 E03 [해석] 뒤에도 E05 가 「2D 해석 결과가 없습니다」 — 534-3 은 정한 가상 꼴로만 시험함. Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Build.py | 109 ++++++++++++++-------- resources/tester/test_534_3_e05_report.py | 98 +++++++++++-------- 2 files changed, 129 insertions(+), 78 deletions(-) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index d924938cf..becbb50b0 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -1,7 +1,8 @@ """E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3). -입력 = E02 지반 조건(`ground.json`) · E03 2D 결과 요약(`_derived/result2d.json`) · 측점별 저장본 · -대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E01 복사본(측점 · 횡단 · 노선). +입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 +`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) · +E01 복사본(측점 · 횡단 · 노선). 양식 = `resources/master_template/report/stab_report.json` 칸 자리 = 같은 폴더 `stab_report_slots.md`. @@ -28,7 +29,10 @@ from typing import Any from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model +from common_util.common_util_derived_store import read_derived +from E03_StabSection import E03_StabSection_View as view2d from E03_StabSection.E03_StabSection_Engine_Model import final_surface +from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides from E04_Stab3D import E04_Stab3D_Run as run3d from E05_StabReport import E05_StabReport_Figure as figure @@ -39,12 +43,9 @@ REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") PROGRAM_SHEETS = ("s3", "s4", "s5", "s6") BLOCK = 28 # 대표단면 한 쪽 = 28 행 -E03 = Path("E03_StabSection") -RESULT2D = E03 / "_derived" / "result2d.json" -STATION_RESULTS = E03 / "_derived" / "results" -REPRESENTATIVE = E03 / "representative.json" +REPRESENTATIVE = Path("E03_StabSection") / "representative.json" BAND_KEY = "fig/3d_band.png" -LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N +LOWEST_N = view2d.REPRESENTATIVE_LOWEST CASES = ("dry", "wet", "seismic") SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"} METHOD_NAMES = { @@ -55,9 +56,9 @@ METHOD_NAMES = { "spencer": "Spencer", } REASONS = { - "fill_10m": "쌓기 높이 10 m 이상", - "cut_15m": "깎기 높이 15 m 이상", - "lowest_fs": "최소 안전율 하위", + "fill_height": "쌓기 높이 10 m 이상", + "cut_height": "깎기 높이 15 m 이상", + "lowest": "최소 안전율 하위", "user": "사용자 지정", } GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"} @@ -104,23 +105,62 @@ def _ground(project_root: Path) -> dict[str, Any]: ) -def _station_result(project_root: Path, key: str) -> dict[str, Any] | None: - folder = Path(project_root) / STATION_RESULTS - for name in (key, re.sub(r"[^0-9A-Za-z._+-]", "_", key)): - found = _read(folder / f"{name}.json") - if found: - return found - return None +def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None: + saved = read_derived(Path(project_root), KIND_STATION, fp) + return (saved or {}).get("value", {}).get("out") + + +def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]: + """E03 요약 줄 → 보고서 줄 — 비탈 = 기준 대비 가장 낮은 쪽(fs / 기준) · 판정 = 조건 하나라도 + N.G 면 ng.""" + roles = {s["side"]: s["role"] for s in row.get("sides") or []} + cases = row.get("cases") or {} + worst = min( + ( + (c["fs"] / c["required"], side) + for sides in cases.values() + for side, c in sides.items() + if c.get("fs") is not None and c.get("required") + ), + default=None, + ) + side = worst[1] if worst else None + verdicts = set((row.get("verdict") or {}).values()) + values = [v for v in (row.get("min") or {}).values() if v is not None] + return { + "station_key": row["key"], + "chainage_m": row["chainage_m"], + "fp": row.get("fp"), + "side": roles.get(side), + "fs_by_condition": { + cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides + }, + "fs_min": min(values) if values else None, + "method": method, + "verdict": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, + "representative": row.get("representative") or {}, + } + + +def _result2d(project_root: Path) -> dict[str, Any] | None: + """E03 가장 최근 요약(지금 입력과 달라도 · E03 화면과 같은 판) + 대표 단면 표시.""" + summary, _ = _latest_summary(project_root, "") + if not summary: + return None + rows = [dict(r) for r in summary.get("stations") or []] + view2d.mark_representative(rows, _overrides(project_root)) + method = summary.get("method") or view2d.PRIMARY + return {**summary, "stations": [_row2d(r, method) for r in rows]} def analysis_fingerprint(project_root: Path) -> str: """해석 지문 — 2D 요약 지문 · 대표 단면 · 지반 조건 · 3D 저장본 · 양식이 같으면 같음 (가벼운 읽기만).""" project_root = Path(project_root) - r2 = _read(project_root / RESULT2D) + r2, _ = _latest_summary(project_root, "") folder = run3d.results_dir(project_root) body = { - "2d": [r2.get("input_fingerprint"), r2.get("computed_at")] if r2 else None, + "2d": [r2.get("fingerprint"), r2.get("computed_at")] if r2 else None, "rep": _read(project_root / REPRESENTATIVE), "ground": _ground(project_root), "3d": [ @@ -134,36 +174,31 @@ def analysis_fingerprint(project_root: Path) -> str: return hashlib.sha1(text.encode("utf-8")).hexdigest() -def _representatives(r2: dict[str, Any], rep: dict[str, Any] | None) -> list[dict[str, Any]]: - by_key = {s["station_key"]: s for s in r2.get("stations") or []} - if rep and isinstance(rep.get("stations"), list): - picked = [s for s in rep["stations"] if s.get("enabled", True)] - else: - ranked = sorted( - (s for s in by_key.values() if s.get("fs_min") is not None), key=lambda s: s["fs_min"] - ) - picked = [{"station_key": s["station_key"], "reason": "lowest_fs"} for s in ranked][ - :LOWEST_N - ] - return [ - {**p, "summary": by_key[p["station_key"]]} for p in picked if p["station_key"] in by_key - ] +def _representatives(r2: dict[str, Any]) -> list[dict[str, Any]]: + """E03 대표 단면 켬 — 까닭 = 사용자 켬이면 user · 아니면 자동 첫 까닭.""" + out = [] + for s in r2.get("stations") or []: + mark = s["representative"] + if mark.get("on"): + reason = "user" if mark.get("user") else (mark.get("auto") or ["user"])[0] + out.append({"station_key": s["station_key"], "reason": reason, "summary": s}) + return out def gather(project_root: Path) -> dict[str, Any]: """빌더 입력 한 벌 — 2D 결과가 없으면 `MissingInput`.""" project_root = Path(project_root) - r2 = _read(project_root / RESULT2D) + r2 = _result2d(project_root) if not r2 or not r2.get("stations"): raise MissingInput("2D 해석 결과가 없습니다 — E03 에서 [해석] 뒤 만드세요.") root = source.source_dir(project_root) db = source.db_snapshot(root) ground = _ground(project_root) resolved = {round(float(s["chainage_m"]), 3): s for s in ground_model.resolve_stations(ground)} - reps = _representatives(r2, _read(project_root / REPRESENTATIVE)) + reps = _representatives(r2) for item in reps: chainage = float(item["summary"]["chainage_m"]) - item["result"] = _station_result(project_root, item["station_key"]) + item["result"] = _station_result(project_root, item["summary"]["fp"]) try: item["cross"] = source.cross_section(root, chainage) except KeyError: @@ -426,7 +461,7 @@ def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[s f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m", ) reason = REASONS.get(rep.get("reason"), rep.get("reason") or "") - if rep.get("reason") == "lowest_fs": + if rep.get("reason") == "lowest": reason = f"{reason} {LOWEST_N}" _set(sheet, f"B{2 + dr}", reason) key = fig_key(rep["station_key"]) diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index e4dfc66f6..63c43062c 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1,6 +1,6 @@ """534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. -가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 · +가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. """ @@ -13,6 +13,7 @@ from pathlib import Path import pytest from openpyxl import load_workbook +from common_util.common_util_derived_store import derived_dir, write_derived from common_util.common_util_print_pdf import PrintError from common_util.common_util_spreadsheet import workbook_pdf from common_util.common_util_workbook_xlsx import workbook_to_xlsx @@ -24,7 +25,11 @@ from E05_StabReport import E05_StabReport_Store as store pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") N = 30 # 측점 수 NG_AT = {3, 10, 17, 24} # N.G 측점 -REPS = [("grid:3", "lowest_fs"), ("grid:12", "cut_15m"), ("grid:20", "fill_10m")] +REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")] +# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔 +OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} +KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" +REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} def _key(i: int) -> str: @@ -32,17 +37,49 @@ def _key(i: int) -> str: def _summary(i: int, bump: float = 0.0) -> dict: - ng = i in NG_AT + """E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율.""" + ng = i in NG_AT and not bump dry = 1.31 if ng else 1.7 + i / 100 + bump - return { - "station_key": _key(i), - "chainage_m": 20.0 * i, - "side": "cut" if i % 2 else "fill", - "fs_min": dry - 0.2, - "fs_by_condition": {"dry": dry, "wet": dry - 0.2}, - "method": "bishop", - "verdict": "ng" if ng else "ok", + role = "cut" if i % 2 else "fill" + side = "left" if role == "cut" else "right" + other = "right" if side == "left" else "left" + heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0} + fs = {"dry": dry, "wet": dry - 0.2} + cases = { + cond: { + side: {"fs": fs[cond], "required": REQUIRED[cond][role]}, + other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]}, + } + for cond in fs } + return { + "key": _key(i), + "chainage_m": 20.0 * i, + "label": f"No.{i}", + "fp": f"st{i}", + "sides": [ + {"side": "left", "role": "cut", "height_m": heights["cut"]}, + {"side": "right", "role": "fill", "height_m": heights["fill"]}, + ], + "cases": cases, + "min": {c: min(v["fs"] for v in cases[c].values()) for c in cases}, + "verdict": { + c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng" + for c in cases + }, + "error": None, + } + + +def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: + value = { + "fingerprint": fp, + "computed_at": "2026-10-25T10:00:00", + "conditions": ["dry", "wet"], + "method": "bishop", + "stations": rows, + } + write_derived(root, KIND_SUMMARY, fp, value) def _slip(F: float) -> dict: @@ -54,7 +91,7 @@ def _station_doc(i: int) -> dict: s = _summary(i) cases = [] for cond in ("dry", "wet"): - F = s["fs_by_condition"][cond] + F = s["min"][cond] for side, role in (("left", "cut"), ("right", "fill")): cases.append( { @@ -94,32 +131,13 @@ def _cross(chainage: float) -> dict: @pytest.fixture def project(tmp_path, monkeypatch) -> Path: root = tmp_path / "e_project" - d2 = root / "E03_StabSection" / "_derived" - (d2 / "results").mkdir(parents=True) - (d2 / "result2d.json").write_text( - json.dumps( - { - "input_fingerprint": "fp-1", - "computed_at": "2026-10-25T10:00:00", - "stations": [_summary(i) for i in range(N)], - } - ), - encoding="utf-8", - ) + _write_summary(root, "fp-1", [_summary(i) for i in range(N)]) for key, _ in REPS: i = int(key.split(":")[1]) - (d2 / "results" / f"grid_{i}.json").write_text( - json.dumps(_station_doc(i)), encoding="utf-8" - ) + write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N) + (root / "E03_StabSection").mkdir() (root / "E03_StabSection" / "representative.json").write_text( - json.dumps( - { - "rule": {"fill_h_m": 10, "cut_h_m": 15, "lowest_n": 5}, - "stations": [{"station_key": k, "reason": r, "enabled": True} for k, r in REPS] - + [{"station_key": _key(5), "reason": "user", "enabled": False}], - } - ), - encoding="utf-8", + json.dumps({"overrides": OFF}), encoding="utf-8" ) ground = ground_model.pick(ground_model.load_defaults()) ground["stations"] = [ @@ -216,11 +234,9 @@ def test_갱신_사용자몫_불변_덮임(project): by = _sheets(book) by["측점표"]["칸"]["A6"]["값"] = "고친 측점" store.save(project, book) - r2 = project / build.RESULT2D - doc = json.loads(r2.read_text(encoding="utf-8")) - doc["stations"][3] = _summary(4, 0.5) | {"station_key": _key(3), "chainage_m": 60.0} - doc["input_fingerprint"] = "fp-2" - r2.write_text(json.dumps(doc), encoding="utf-8") + rows = [_summary(i) for i in range(N)] + rows[3] = _summary(3, 0.5) + _write_summary(project, "fp-2", rows) assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"] try: out = store.refresh(project) @@ -251,7 +267,7 @@ def test_3D_없음_2D_없음(project): by = _sheets(book) assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전" assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전" - (project / build.RESULT2D).unlink() + shutil.rmtree(derived_dir(project, KIND_SUMMARY)) with pytest.raises(build.MissingInput): build.gather(project) From f2e8bd6d5fba5355b28b91154bc36c69e2e11704 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:26:37 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat(536-1):=20E06=20=EC=95=94=EB=B0=98?= =?UTF-8?q?=20=ED=8F=89=EC=82=AC=ED=88=AC=EC=98=81=20=E2=80=94=20Markland?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95=20=C2=B7=20Hoek-Bray=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=20=C2=B7=20=EC=90=90=EA=B8=B0=20=EC=95=88=EC=A0=84=EC=9C=A8=20?= =?UTF-8?q?=C2=B7=20=EC=8A=A4=ED=85=8C=EB=A0=88=EC=98=A4=EB=84=B7=20=C2=B7?= =?UTF-8?q?=20=EC=A0=88=EB=A6=AC=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진(자체 · NumPy · SciPy): 평면 · 쐐기(교선) · 전도 Markland(측방 한계 20° 기본) · 평면 Hoek-Bray(인장균열 없음 · 임계 · 깊이 · 수압 · kh · 수동 볼트) · 쐐기 SWedge 꼴(미끄럼 방향 8 경우 · 두 면 · 한 면 · 수압 중력식 · kh · 볼트) - 스테레오넷 좌표(등각 · 등면적 하반구 · 극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역 = 판정 부등식) - 길: 절리 작업본 E06_StabRock/joints.json(초기값 · 저장 · 초기화) · 해석 = 암 측점 깎기 쪽만 · 파생 저장본 stab_rock · 결과 · 측점 스테레오넷 - 화면: 좌 절리군 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우 측점 결과 · 안전율 표 · E 단계 막대에 E06(끝에 덧붙임) - 시험 test_536_1(Hoek Sau Mau Ping 0.8184 · 1.0655 · Sharma 윗면 무게 · Hoek-Bray 쐐기 계수 · Kumsar · Priest 두 예제 · Markland 경계 · 투영 왕복 · 위험 영역 = 판정) · test_528 E 단계 수 4 → 5 Co-Authored-By: Claude Opus 5.5 --- A00_Common/group_workflow.ts | 10 +- E06_StabRock/E06_StabRock_Engine.py | 444 ++++++++++++++++++ E06_StabRock/E06_StabRock_Model.py | 446 ++++++++++++++++++ E06_StabRock/E06_StabRock_Router.py | 204 ++++++++ E06_StabRock/E06_StabRock_Stereonet.py | 137 ++++++ E06_StabRock/E06_StabRock_UI_Joints.ts | 304 ++++++++++++ E06_StabRock/E06_StabRock_UI_Page.ts | 397 ++++++++++++++++ E06_StabRock/E06_StabRock_UI_Result.ts | 219 +++++++++ E06_StabRock/E06_StabRock_UI_Stereonet.ts | 193 ++++++++ E06_StabRock/E06_StabRock_UI_Style.css | 485 ++++++++++++++++++++ E06_StabRock/draft/E06_StabRock_Route.ts | 16 + common_util/common_util_project_kind.py | 8 +- common_util/common_util_workflow_state.py | 4 +- config/config_frontend.ts | 4 +- config/config_system.py | 1 + main.py | 2 + resources/tester/test_528_project_groups.py | 2 +- resources/tester/test_536_1_e06_rock.py | 426 +++++++++++++++++ ui_template/ui_template_locale.ts | 2 + ui_template/ui_template_locale_de.ts | 1 + ui_template/ui_template_locale_e06.ts | 122 +++++ 21 files changed, 3420 insertions(+), 7 deletions(-) create mode 100644 E06_StabRock/E06_StabRock_Engine.py create mode 100644 E06_StabRock/E06_StabRock_Model.py create mode 100644 E06_StabRock/E06_StabRock_Router.py create mode 100644 E06_StabRock/E06_StabRock_Stereonet.py create mode 100644 E06_StabRock/E06_StabRock_UI_Joints.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Page.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Result.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Stereonet.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Style.css create mode 100644 E06_StabRock/draft/E06_StabRock_Route.ts create mode 100644 resources/tester/test_536_1_e06_rock.py create mode 100644 ui_template/ui_template_locale_e06.ts diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 0fa1d7e21..0bde3c863 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -31,8 +31,14 @@ const FLOWS: readonly GroupFlow[] = [ }, { routes: STAB_STEP_ROUTES, - labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], - icons: ["🪨", "📐", "🧊", "📄"], + labels: [ + "DE_Step_StabGround", + "DE_Step_StabSection", + "DE_Step_Stab3D", + "DE_Step_StabReport", + "DE_Step_StabRock", + ], + icons: ["🪨", "📐", "🧊", "📄", "⛰️"], }, ]; diff --git a/E06_StabRock/E06_StabRock_Engine.py b/E06_StabRock/E06_StabRock_Engine.py new file mode 100644 index 000000000..a69b468b3 --- /dev/null +++ b/E06_StabRock/E06_StabRock_Engine.py @@ -0,0 +1,444 @@ +"""E06 암반 비탈면 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 (요구 345 · 351 · PLAN 536-1). + +근거 = 지식DB `original/안정성평가/03`(Markland 부등식 · Hoek-Bray 평면 · 쐐기) · 06(상용 SW) · +Rocscience RocPlane · SWedge 이론서(힘 벡터 · 미끄럼 방향 8 경우 · 수직력 · 지지력은 수동력). +코드는 직접 짬(Dips · RocPlane · SWedge 코드 안 씀) · NumPy · SciPy 만. + +좌표 = 동(+x) · 북(+y) · 위(+z). 면 = (경사방향 α · 경사 ψ) · 선 = (추향 · 선주각). +단위 = 길이 m · 단위중량 kN/m³ · 점착력 kPa · 각 ° · 힘 kN(평면 = 단위 폭당 kN/m). + +⚠ 지식DB 03 의 평면 볼트 항(수직 T cos(θ+ψp) · 활동 −T sin(θ+ψp))은 θ = 볼트와 면 법선 사이 각일 +때 맞음 — 여기는 θ = 볼트 경사(수평 아래)로 받아 힘 벡터로 풂(수직 T sin(θ+ψp) · 활동 −T cos(θ+ψp)). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from scipy.optimize import minimize_scalar + +GAMMA_W = 9.81 # 물 단위중량 kN/m³ (E03 Types 와 같음) +LATERAL_PLANAR_DEG = 20.0 # 평면 측방 한계(Dips 기본 · Wyllie & Mah) +LATERAL_TOPPLING_DEG = 20.0 # 전도 측방 한계(Dips 기본 · 지식DB 03 은 30 — 화면에서 바꿈) + + +# ---- 방향 ---------------------------------------------------------------------- +def ang_diff(a: float, b: float) -> float: + """두 방위각 사이 최소 각거리 0~180.""" + d = abs((a - b) % 360.0) + return min(d, 360.0 - d) + + +def plane_normal(dip_dir: float, dip: float) -> np.ndarray: + """면의 위쪽 단위 법선.""" + a, p = math.radians(dip_dir), math.radians(dip) + return np.array([math.sin(a) * math.sin(p), math.cos(a) * math.sin(p), math.cos(p)]) + + +def line_vector(trend: float, plunge: float) -> np.ndarray: + """선(추향 · 선주각) → 아래로 향한 단위 벡터.""" + t, p = math.radians(trend), math.radians(plunge) + return np.array([math.sin(t) * math.cos(p), math.cos(t) * math.cos(p), -math.sin(p)]) + + +def vector_line(v: np.ndarray) -> tuple[float, float]: + """벡터 → (추향 · 선주각) 하반구(위로 향하면 뒤집음).""" + v = np.asarray(v, float) + if v[2] > 0: + v = -v + v = v / np.linalg.norm(v) + trend = math.degrees(math.atan2(v[0], v[1])) % 360.0 + return trend, math.degrees(math.asin(max(-1.0, min(1.0, -v[2])))) + + +def pole(dip_dir: float, dip: float) -> tuple[float, float]: + """면의 극점(하반구) = (α + 180 · 90 − ψ).""" + return (dip_dir + 180.0) % 360.0, 90.0 - dip + + +def intersection(a: dict, b: dict) -> tuple[float, float] | None: + """두 면 교선(추향 · 선주각) — 나란하면 None.""" + v = np.cross( + plane_normal(a["dip_direction"], a["dip"]), plane_normal(b["dip_direction"], b["dip"]) + ) + if np.linalg.norm(v) < 1e-9: + return None + return vector_line(v) + + +def apparent_dip(dip: float, dip_dir: float, trend: float) -> float: + """면 (α · ψ) 의 trend 방향 겉보기 경사 — 반대쪽(90° 넘음)이면 음수.""" + return math.degrees( + math.atan(math.tan(math.radians(dip)) * math.cos(math.radians(trend - dip_dir))) + ) + + +def strike_to_dip_direction(strike: float) -> float: + """오른손 법칙 주향 → 경사방향.""" + return (strike + 90.0) % 360.0 + + +# ---- Markland 판정 ------------------------------------------------------------- +def kinematic( + slope: dict, + joints: list[dict], + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, list]: + """slope = {dip_direction, dip} · joints = [{id, dip_direction, dip, phi}, …]. + + 평면 = Δ(αf, αp) ≤ 측방 · φ < ψp < 겉보기 사면 경사 / 쐐기 = 교선 Δ < 90 · φ < 선주각 < + 겉보기 사면 경사(φ = 두 면 중 작은 값) / + 전도 = Δ(αf + 180, αp) ≤ 측방 · ψp > 90 − ψf + φ (Goodman). + """ + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + planar, toppling, wedge = [], [], [] + for j in joints: + aj, pj, phi = float(j["dip_direction"]), float(j["dip"]), float(j["phi"]) + if ang_diff(af, aj) <= lateral_planar and phi < pj < apparent_dip(pf, af, aj): + planar.append(j["id"]) + if ang_diff(af + 180.0, aj) <= lateral_toppling and pj > 90.0 - pf + phi: + toppling.append(j["id"]) + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is None: + continue + trend, plunge = line + phi = min(float(a["phi"]), float(b["phi"])) + if ang_diff(af, trend) < 90.0 and phi < plunge < apparent_dip(pf, af, trend): + wedge.append( + { + "ids": [a["id"], b["id"]], + "trend": round(trend, 2), + "plunge": round(plunge, 2), + } + ) + return {"planar": planar, "wedge": wedge, "toppling": toppling} + + +# ---- 평면 파괴 (Hoek-Bray · 2D 단위 폭) ----------------------------------------- +def _plane_block(H: float, pf: float, pp: float, ps: float, crack_x: float | None): + """사면 끝(0,0) · x = 산 쪽 수평 · z = 위. 덩이 다각형 · 활동면 끝 x · 균열 깊이. + + 윗면 = 마루(H cotψf, H)에서 ψs 로 오름. 균열 없음 = 활동면이 윗면과 만나는 점까지.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf # 마루 + + def top(x: float) -> float: + return x * tf if x <= xc else H + (x - xc) * ts + + if crack_x is None: + if ts >= tp - 1e-12: + return None # 활동면이 윗면과 안 만남 = 덩이 안 생김 + xe = (H - xc * ts) / (tp - ts) + poly = [(0.0, 0.0), (xc, H), (xe, xe * tp)] + return poly, xe, 0.0 + if crack_x < xc or top(crack_x) - crack_x * tp <= 1e-9: + return None + poly = [(0.0, 0.0), (xc, H), (crack_x, top(crack_x)), (crack_x, crack_x * tp)] + return poly, crack_x, top(crack_x) - crack_x * tp + + +def _area(poly) -> float: + xs, zs = np.array(poly).T + return 0.5 * abs(float(np.dot(xs, np.roll(zs, -1)) - np.dot(zs, np.roll(xs, -1)))) + + +def plane_fs( + *, + H: float, + slope_dip: float, + plane_dip: float, + c: float, + phi: float, + gamma: float, + upper_dip: float = 0.0, + crack: dict | None = None, + water_fill: float = 0.0, + kh: float = 0.0, + bolt_t: float = 0.0, + bolt_plunge: float = 0.0, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """평면 파괴 안전율 — 덩이가 안 생기면 None. + + crack = None | {"mode": "depth", "z": m} | {"mode": "critical"}(안전율 최소 자리 찾음) · + 물 = 균열 있음: zw = fill·z · U = ½γw zw A · V = ½γw zw² / 균열 없음: 가운데 최대 수두 + fill·h/2(h = 활동면 오름) · U = ½γw (fill·h/2) A (Hoek · RocPlane) · + kh = 바깥 수평 · 볼트 = 수동력(산 쪽 · 수평 아래 bolt_plunge°).""" + if not 0 < plane_dip < slope_dip < 90 or H <= 0 or upper_dip >= slope_dip: + return None + mode = (crack or {}).get("mode", "none") + if mode == "critical": + return _critical_crack(locals()) + tp = math.tan(math.radians(plane_dip)) + xc = H / math.tan(math.radians(slope_dip)) + crack_x = None + if mode == "depth": + z = float(crack["z"]) + crack_x = _crack_x_for_depth(H, slope_dip, plane_dip, upper_dip, z) + if crack_x is None: + return None + elif mode == "x": + crack_x = xc + float(crack["b"]) + block = _plane_block(H, slope_dip, plane_dip, upper_dip, crack_x) + if block is None: + return None + poly, x_end, z = block + psi = math.radians(plane_dip) + A = x_end / math.cos(psi) + W = gamma * _area(poly) + if crack_x is not None: + zw = water_fill * z + U, V = 0.5 * gamma_w * zw * A, 0.5 * gamma_w * zw * zw + else: + zw = water_fill * (x_end * tp) / 2.0 + U, V = 0.5 * gamma_w * zw * A, 0.0 + d = np.array([-math.cos(psi), -math.sin(psi)]) # 아래로 미끄러짐(바깥 = −x) + n = np.array([-math.sin(psi), math.cos(psi)]) # 면에서 덩이 쪽 + active = np.array([-kh * W - V, -W]) + U * n + drive = float(active @ d) + if drive <= 0: + return None + bolt = bolt_t * np.array( + [math.cos(math.radians(bolt_plunge)), -math.sin(math.radians(bolt_plunge))] + ) + N = max(0.0, -float((active + bolt) @ n)) + resist = c * A + N * math.tan(math.radians(phi)) - float(bolt @ d) + return { + "F": resist / drive, + "W": W, + "A": A, + "U": U, + "V": V, + "z": z, + "zw": zw, + "crack_b": None if crack_x is None else crack_x - xc, + "N": N, + "drive": drive, + "resist": resist, + "polygon": [[round(x, 3), round(y, 3)] for x, y in poly], + } + + +def _crack_x_for_depth(H, pf, pp, ps, z) -> float | None: + """윗면 아래 균열 깊이 z 가 되는 자리(마루 뒤) — 깊이가 마루 틈보다 크면 None.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf + gap = H - xc * tp # 마루에서 윗면 − 활동면 + if z <= 0 or z >= gap: + return None + if ts >= tp: # 틈이 커지기만 함 = 뒤로 갈수록 깊어짐 + return xc + (z - gap) / (ts - tp) if ts > tp else None + return xc + (gap - z) / (tp - ts) + + +def plane_critical_depth_hoek(H: float, slope_dip: float, plane_dip: float) -> float: + """Hoek-Bray 임계 인장균열 깊이(윗면 수평 · 건조) z = H(1 − √(cotψf tanψp)).""" + return H * ( + 1.0 - math.sqrt(math.tan(math.radians(plane_dip)) / math.tan(math.radians(slope_dip))) + ) + + +def _critical_crack(kw: dict) -> dict | None: + """균열 자리를 마루 뒤로 옮겨 안전율 최소 — 균열 없음보다 크면 균열 없음.""" + args = {k: kw[k] for k in kw if k not in ("crack", "mode")} + tf, tp, ts = (math.tan(math.radians(kw[k])) for k in ("slope_dip", "plane_dip", "upper_dip")) + xc = kw["H"] / tf + x_far = (kw["H"] - xc * ts) / (tp - ts) if ts < tp else xc + 10 * kw["H"] + span = x_far - xc + + def fs_at(b: float) -> float: + r = plane_fs(**args, crack={"mode": "x", "b": b}) + return r["F"] if r else math.inf + + best = minimize_scalar(fs_at, bounds=(span * 1e-3, span * (1 - 1e-3)), method="bounded") + found = plane_fs(**args, crack={"mode": "x", "b": float(best.x)}) + plain = plane_fs(**args, crack=None) + if found is None or (plain is not None and plain["F"] <= found["F"]): + return plain + return found + + +# ---- 쐐기 파괴 (Hoek-Bray · SWedge 꼴 사면체) ------------------------------------ +def _meet(planes: list[tuple[np.ndarray, float]]) -> np.ndarray | None: + """세 면 n·x = d 의 교점.""" + m = np.array([p[0] for p in planes]) + if abs(np.linalg.det(m)) < 1e-10: + return None + return np.linalg.solve(m, np.array([p[1] for p in planes])) + + +def _tri_area(a, b, c) -> float: + return 0.5 * float(np.linalg.norm(np.cross(b - a, c - a))) + + +def wedge_geometry(j1: dict, j2: dict, slope: dict, upper: dict, H: float) -> dict | None: + """사면체 쐐기 — 꼭짓점 O(두 절리 · 사면 = 사면 끝) · A(1 · 사면 · 윗면) · B(2 · 사면 · 윗면) · + C(1 · 2 · 윗면). 사면 높이 H = 사면 위 꼭짓점 A · B 중 높은 쪽의 O 위 높이(SWedge 와 같음 — + Priest 예제 부피 45.201 m³ 재현). 교선이 사면 · 윗면으로 안 나오면 None(쐐기 안 생김).""" + n1 = plane_normal(j1["dip_direction"], j1["dip"]) + n2 = plane_normal(j2["dip_direction"], j2["dip"]) + nf = plane_normal(slope["dip_direction"], slope["dip"]) + nu = plane_normal(upper["dip_direction"], upper["dip"]) + # 윗면 = 사면 위로 H 오른 점을 지남(사면 경사방향 반대로 H cotψf 들어간 점) + back = -line_vector(slope["dip_direction"], 0.0) * (H / math.tan(math.radians(slope["dip"]))) + crest = back + np.array([0.0, 0.0, H]) + du = float(nu @ crest) + O = np.zeros(3) # noqa: E741 — 꼭짓점 이름(SWedge 그림과 같음) + A = _meet([(n1, 0.0), (nf, 0.0), (nu, du)]) + B = _meet([(n2, 0.0), (nf, 0.0), (nu, du)]) + C = _meet([(n1, 0.0), (n2, 0.0), (nu, du)]) + if A is None or B is None or C is None: + return None + # C 는 사면 뒤(산 쪽) · 위 — 교선이 사면으로 나와 윗면에 닿음 + if C[2] <= 1e-9 or float(nf @ C) >= -1e-9 or A[2] <= 1e-9 or B[2] <= 1e-9: + return None + scale = H / max(A[2], B[2]) # 면이 모두 O 를 지나 닮은꼴 — 높이만 맞춤 + A, B, C = A * scale, B * scale, C * scale + volume = abs(float(np.linalg.det(np.array([A - O, B - O, C - O])))) / 6.0 + if volume <= 1e-9: + return None + centroid = (O + A + B + C) / 4.0 + inward = [n if float(n @ centroid) > 0 else -n for n in (n1, n2)] + return { + "vertices": {"O": O, "A": A, "B": B, "C": C}, + "volume": volume, + "areas": [_tri_area(O, A, C), _tri_area(O, B, C)], + "face_area": _tri_area(O, A, B), + "upper_area": _tri_area(A, B, C), + "normals": inward, + "Hw": float(C[2]), + "line_length": float(np.linalg.norm(C)), + } + + +def _unit(v: np.ndarray) -> np.ndarray: + n = float(np.linalg.norm(v)) + return v / n if n > 1e-12 else v * 0.0 + + +def _sliding(A: np.ndarray, n1: np.ndarray, n2: np.ndarray, W: np.ndarray): + """SWedge 미끄럼 방향 — (갈래, 방향). 갈래 = lift · 1 · 2 · 12 · stable.""" + s1 = _unit(np.cross(np.cross(n1, A), n1)) + s2 = _unit(np.cross(np.cross(n2, A), n2)) + c12 = np.cross(n1, n2) + s12 = _unit(c12) * np.sign(float(c12 @ A)) + if A @ n1 > 0 and A @ n2 > 0: + return "lift", _unit(A) + if A @ n1 <= 0 and s1 @ n2 > 0: + return "1", s1 + if A @ n2 <= 0 and s2 @ n1 > 0: + return "2", s2 + if s1 @ n2 <= 0 and s2 @ n1 <= 0: + return "12", s12 + return "stable", None + + +def _normals(mode: str, F: np.ndarray, n1: np.ndarray, n2: np.ndarray) -> tuple[float, float]: + if mode == "1": + return -float(F @ n1), 0.0 + if mode == "2": + return 0.0, -float(F @ n2) + if mode == "12": + c12, c21 = np.cross(n1, n2), np.cross(n2, n1) + return ( + -float(np.cross(F, n2) @ c12) / float(c12 @ c12), + -float(np.cross(F, n1) @ c21) / float(c21 @ c21), + ) + return 0.0, 0.0 + + +def wedge_fs( + *, + j1: dict, + j2: dict, + slope: dict, + upper: dict, + H: float, + gamma: float, + water: dict | None = None, + kh: float = 0.0, + seismic_trend: float | None = None, + external: list[dict] | None = None, + bolt: dict | None = None, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """쐐기 안전율(SWedge 이론서 꼴) — 쐐기가 안 생기면 None. + + j = {dip_direction, dip, c, phi} · water = {"fill": p}(중력식 U = p³γw Hw a/6) | + {"pressure": [u1, u2]}(평균 수압 kPa) · kh = 수평(기본 교선 추향) · external = [{force, trend, + plunge}] · bolt = {force, trend, plunge}(수동력). FS = max(지지 전 · 지지 뒤 · 들림).""" + geo = wedge_geometry(j1, j2, slope, upper, H) + if geo is None: + return None + n1, n2 = geo["normals"] + a1, a2 = geo["areas"] + weight = gamma * geo["volume"] + Wv = np.array([0.0, 0.0, -weight]) + active = Wv.copy() + water = water or {} + if "pressure" in water: + u1, u2 = (float(u) for u in water["pressure"]) + else: + p = float(water.get("fill", 0.0)) + u1 = u2 = p**3 * gamma_w * geo["Hw"] / 6.0 + Uf = [u1 * a1 * n1, u2 * a2 * n2] + active += Uf[0] + Uf[1] + line = vector_line(np.cross(n1, n2)) + if kh: + trend = line[0] if seismic_trend is None else seismic_trend + active += kh * weight * line_vector(trend, 0.0) + for item in external or []: + active += float(item["force"]) * line_vector(item["trend"], item["plunge"]) + passive = np.zeros(3) + if bolt and bolt.get("force"): + passive = float(bolt["force"]) * line_vector(bolt["trend"], bolt["plunge"]) + mode, s = _sliding(active, n1, n2, Wv) + out = { + "volume": geo["volume"], + "W": weight, + "areas": [a1, a2], + "face_area": geo["face_area"], + "upper_area": geo["upper_area"], + "Hw": geo["Hw"], + "line_length": geo["line_length"], + "line": [round(line[0], 2), round(line[1], 2)], + "water_force": [float(np.linalg.norm(Uf[0])), float(np.linalg.norm(Uf[1]))], + "mode": mode, + "vertices": {k: [round(float(x), 3) for x in v] for k, v in geo["vertices"].items()}, + } + if mode == "stable": + return {**out, "F": math.inf, "N": [0.0, 0.0], "drive": 0.0, "resist": 0.0} + drive = float(active @ s) + if mode == "lift": + fs = -float(passive @ s) / drive if drive > 0 else math.inf + return {**out, "F": fs, "N": [0.0, 0.0], "drive": drive, "resist": -float(passive @ s)} + joints = (j1, j2) + + def resisting(F: np.ndarray) -> tuple[float, list[float]]: + N = _normals(mode, F, n1, n2) + total = 0.0 + for k, (n_k, a_k) in enumerate(zip(N, (a1, a2))): + if str(k + 1) not in mode or n_k <= 0: + continue # 떨어진 면 = 저항 없음(SWedge 와 같음) + tau = float(joints[k]["c"]) + n_k / a_k * math.tan( + math.radians(float(joints[k]["phi"])) + ) + total += tau * a_k + return total, [max(0.0, n) for n in N] + + j_u, n_u = resisting(active) + fs_u = j_u / drive if drive > 0 else math.inf + fs, n_used, resist = fs_u, n_u, j_u + if np.any(passive): + j_s, n_s = resisting(active + passive) + fs_s = (-float(passive @ s) + j_s) / drive if drive > 0 else math.inf + fs_lift = -float(passive @ _unit(active)) / float(np.linalg.norm(active)) + if fs_s > fs: + fs, n_used, resist = fs_s, n_s, -float(passive @ s) + j_s + fs = max(fs, fs_lift) + return {**out, "F": fs, "N": n_used, "drive": drive, "resist": resist} diff --git a/E06_StabRock/E06_StabRock_Model.py b/E06_StabRock/E06_StabRock_Model.py new file mode 100644 index 000000000..2cfe7d1ae --- /dev/null +++ b/E06_StabRock/E06_StabRock_Model.py @@ -0,0 +1,446 @@ +"""E06 절리 정본 꼴 · 검사 · 측점 해석 묶음 (요구 345 · 351 · PLAN 536-1). + +정본 = `E06_StabRock/joints.json` · 초기값 = `initial_snapshot/E06_StabRock/joints.json` +(절리 없음 + 기본 설정 · 처음 GET 때 한 번). 절리군 한 줄 = 구간(from_m ~ to_m) · 경사방향 · 경사 · +φj · cj · 지속성(선택). 측점 = 복사본 암 측점(리핑암 · 발파암)의 깎기 쪽만. + +사면 = 측점 frame 방향 + 깎기 쪽(경사방향 = 노면 쪽) · 경사 = 깎기(암) 경사비 · 높이 = E03 과 같은 +`side_cases` 높이 · 윗면 = 비탈 끝 바깥 원지반 경사. 물성 · 지진 · 기준안전율 = E02 지반 조건. +""" + +from __future__ import annotations + +import copy +import hashlib +import math +from functools import lru_cache +from pathlib import Path +from typing import Any + +import numpy as np + +from common_util.common_util_station_key import chainage_key +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection.E03_StabSection_Engine_Model import ground_from_cross, side_cases +from E06_StabRock import E06_StabRock_Engine as engine +from E06_StabRock.E06_StabRock_Stereonet import PROJECTIONS, stereonet + +STAGE_DIR = "E06_StabRock" +JOINTS_NAME = "joints.json" +VERSION = 1 +ENGINE_FILES = ("E06_StabRock_Engine.py", "E06_StabRock_Stereonet.py", "E06_StabRock_Model.py") +DEFAULT_GAMMA_R = 25.0 # 암 단위중량 — E02 암 물성이 없을 때(지식DB 07 Hoek & Bray 25~27) +MIN_HEIGHT_M = 1.0 # 이보다 낮은 깎기는 암반 블록 검토 안 함 +CRACK_MODES = ("none", "critical", "depth") +CONDITIONS = ("dry", "wet", "seismic") + +DEFAULT_SETTINGS: dict[str, Any] = { + "projection": "equal_angle", + "lateral_planar_deg": engine.LATERAL_PLANAR_DEG, + "lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG, + "gamma_r": None, # None = E02 측점 암 물성 γt + "water_fill_wet": 0.5, # 우기 수위 = 비탈 높이 ½(KDS 11 70 05 표 4.3-3 암반) + "crack": {"mode": "none", "z": None}, + "bolt": {"force": 0.0, "plunge": 15.0}, +} + +# 칸 범위 — 화면 입력 · 서버 검사 같은 값 +LIMITS: dict[str, list[float]] = { + "chainage_m": [0.0, 100000.0], + "dip_direction": [0.0, 360.0], + "dip": [0.0, 90.0], + "phi": [0.0, 60.0], + "c": [0.0, 1000.0], + "persistence_m": [0.0, 1000.0], + "lateral_deg": [5.0, 45.0], + "gamma_r": [15.0, 35.0], + "water_fill_wet": [0.0, 1.0], + "crack_z": [0.1, 100.0], + "bolt_force": [0.0, 5000.0], + "bolt_plunge": [-30.0, 60.0], +} + +# 기본 절리면 값(새 줄) — 지식DB 07 표 7: cj 0~30 · φj 25~35(Barton 기본마찰각) 보수 쪽 +NEW_SET = {"phi": 30.0, "c": 0.0} + + +def joints_path(project_root: Path) -> Path: + return Path(project_root) / STAGE_DIR / JOINTS_NAME + + +def initial_path(project_root: Path) -> Path: + return Path(project_root) / "initial_snapshot" / STAGE_DIR / JOINTS_NAME + + +def build_initial() -> dict[str, Any]: + return {"version": VERSION, "sets": [], "settings": copy.deepcopy(DEFAULT_SETTINGS)} + + +def upgrade(doc: dict[str, Any]) -> dict[str, Any]: + """빠진 설정 칸만 기본값으로 채운 사본.""" + out = copy.deepcopy(doc) + settings = out.setdefault("settings", {}) + for key, value in DEFAULT_SETTINGS.items(): + settings.setdefault(key, copy.deepcopy(value)) + out.setdefault("sets", []) + return out + + +# ---- 검사 ---------------------------------------------------------------------- +def _num(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def _in(value: Any, key: str) -> bool: + lo, hi = LIMITS[key] + return _num(value) and lo <= value <= hi + + +def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]: + """틀린 칸 [{path, code}] — code = range · order · name · id_dup · enum · type.""" + errors: list[dict[str, str]] = [] + + def bad(path: str, code: str = "range") -> None: + errors.append({"path": path, "code": code}) + + sets = doc.get("sets") + if not isinstance(sets, list): + return [{"path": "sets", "code": "type"}] + seen: set[str] = set() + for k, row in enumerate(sets): + base = f"sets.{k}" + if not isinstance(row, dict): + bad(base, "type") + continue + ident = str(row.get("id") or "") + if not ident or ident in seen: + bad(f"{base}.id", "id_dup") + seen.add(ident) + if not str(row.get("name") or "").strip(): + bad(f"{base}.name", "name") + for key, limit in ( + ("from_m", "chainage_m"), + ("to_m", "chainage_m"), + ("dip_direction", "dip_direction"), + ("dip", "dip"), + ("phi", "phi"), + ("c", "c"), + ): + if not _in(row.get(key), limit): + bad(f"{base}.{key}") + if _num(row.get("from_m")) and _num(row.get("to_m")) and row["to_m"] < row["from_m"]: + bad(f"{base}.to_m", "order") + if row.get("persistence_m") is not None and not _in(row["persistence_m"], "persistence_m"): + bad(f"{base}.persistence_m") + settings = doc.get("settings") + if not isinstance(settings, dict): + return errors + [{"path": "settings", "code": "type"}] + if settings.get("projection") not in PROJECTIONS: + bad("settings.projection", "enum") + for key in ("lateral_planar_deg", "lateral_toppling_deg"): + if not _in(settings.get(key), "lateral_deg"): + bad(f"settings.{key}") + if settings.get("gamma_r") is not None and not _in(settings["gamma_r"], "gamma_r"): + bad("settings.gamma_r") + if not _in(settings.get("water_fill_wet"), "water_fill_wet"): + bad("settings.water_fill_wet") + crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {} + if crack.get("mode") not in CRACK_MODES: + bad("settings.crack.mode", "enum") + elif crack["mode"] == "depth" and not _in(crack.get("z"), "crack_z"): + bad("settings.crack.z") + bolt = settings.get("bolt") if isinstance(settings.get("bolt"), dict) else {} + if not _in(bolt.get("force"), "bolt_force"): + bad("settings.bolt.force") + if not _in(bolt.get("plunge"), "bolt_plunge"): + bad("settings.bolt.plunge") + return errors + + +def clean(doc: dict[str, Any]) -> dict[str, Any]: + """정본 칸만 · 숫자는 float.""" + sets = [] + for row in doc["sets"]: + item = { + "id": str(row["id"]), + "name": str(row["name"]).strip(), + **{ + key: float(row[key]) + for key in ("from_m", "to_m", "dip_direction", "dip", "phi", "c") + }, + "persistence_m": None + if row.get("persistence_m") is None + else float(row["persistence_m"]), + } + item["dip_direction"] %= 360.0 + sets.append(item) + s = doc["settings"] + crack = s["crack"] + return { + "version": VERSION, + "sets": sets, + "settings": { + "projection": s["projection"], + "lateral_planar_deg": float(s["lateral_planar_deg"]), + "lateral_toppling_deg": float(s["lateral_toppling_deg"]), + "gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]), + "water_fill_wet": float(s["water_fill_wet"]), + "crack": { + "mode": crack["mode"], + "z": float(crack["z"]) if crack["mode"] == "depth" else None, + }, + "bolt": {"force": float(s["bolt"]["force"]), "plunge": float(s["bolt"]["plunge"])}, + }, + } + + +# ---- 측점 사면 ------------------------------------------------------------------ +def rock_stations(source: Path) -> list[dict[str, Any]]: + """암 측점(리핑암 · 발파암).""" + return [s for s in src.stations(source) if s.get("ground_type") in ground_model.ROCK_LABELS] + + +def _upper_dip(ground: list, start: float, sign: float, H: float, face_dip: float) -> float: + """비탈 끝 바깥 max(H, 5 m) 원지반 평균 경사(오르막만 · 사면 경사 − 1° 아래).""" + gx = np.array([p[0] for p in ground]) + gz = np.array([p[1] for p in ground]) + end = start + sign * max(H, 5.0) + end = min(max(end, gx[0]), gx[-1]) + run = abs(end - start) + if run < 0.5: + return 0.0 + rise = float(np.interp(end, gx, gz) - np.interp(start, gx, gz)) + return float(min(max(0.0, math.degrees(math.atan2(rise, run))), face_dip - 1.0)) + + +def station_slopes(cross: dict, design: dict) -> list[dict[str, Any]]: + """측점의 깎기 쪽마다 사면 — {side, dip_direction, dip, height_m, upper_dip}.""" + ground = ground_from_cross(cross) + line = sorted( + (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] + ) + ratio = design.get("cut_slope_ratio") + if len(ground) < 2 or len(line) < 2 or not ratio: + return [] + face_dip = math.degrees(math.atan2(1.0, float(ratio))) + left = np.array([float(v) for v in cross["frame"]["left_xy"]]) + out = [] + for case in side_cases(design, ground, line): + if case["role"] != "cut" or case["height_m"] < MIN_HEIGHT_M: + continue + side = case["side"] + sign = 1.0 if side == "left" else -1.0 + # 비탈 끝 = 탐색 끝에서 되짚음(side_cases: reach = 비탈 끝 + sign · max(5, 2H)) + day = case["limits"][1 if sign > 0 else 0] - sign * max(5.0, 2.0 * case["height_m"]) + face = -sign * left # 노면 쪽으로 기움 + dip_dir = math.degrees(math.atan2(face[0], face[1])) % 360.0 + out.append( + { + "side": side, + "dip_direction": round(dip_dir, 2), + "dip": round(face_dip, 2), + "height_m": round(case["height_m"], 3), + "upper_dip": round(_upper_dip(ground, day, sign, case["height_m"], face_dip), 2), + } + ) + return out + + +# ---- 해석 ---------------------------------------------------------------------- +@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_inputs(ground: dict[str, Any]) -> dict[str, Any]: + """E02 에서 E06 이 쓰는 칸 — 지문에도 이것만.""" + return { + k: ground.get(k) + for k in ("materials", "label_map", "stations", "zones", "safety_factors", "seismic") + } + + +def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -> float: + if override: + return float(override) + mat = materials.get((resolved or {}).get("lower") or (resolved or {}).get("upper")) + return float(mat["gamma_t"]) if mat else DEFAULT_GAMMA_R + + +def _fmt(value: float | None) -> float | None: + if value is None or not math.isfinite(value): + return None + return round(value, 4) + + +def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds): + """위험 모드만 안전율 — 조건 = [(이름, 수위 비, kh)].""" + upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]} + by_id = {j["id"]: j for j in joints} + bolt = settings["bolt"] + crack = settings["crack"] if settings["crack"]["mode"] != "none" else None + out = [] + for jid in kin["planar"]: + j = by_id[jid] + for name, fill, kh in conds: + r = engine.plane_fs( + H=slope["height_m"], + slope_dip=slope["dip"], + plane_dip=j["dip"], + c=j["c"], + phi=j["phi"], + gamma=gamma, + upper_dip=slope["upper_dip"], + crack=crack, + water_fill=fill, + kh=kh, + bolt_t=bolt["force"], + bolt_plunge=bolt["plunge"], + ) + short = ( + r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"] + ) + out.append( + { + "mode": "planar", + "ids": [jid], + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "W": _fmt(r["W"]) if r else None, + "A": _fmt(r["A"]) if r else None, + "z": _fmt(r["z"]) if r else None, + } + ) + for item in kin["wedge"]: + a, b = (by_id[k] for k in item["ids"]) + for name, fill, kh in conds: + r = engine.wedge_fs( + j1=a, + j2=b, + slope=slope, + upper=upper, + H=slope["height_m"], + gamma=gamma, + water={"fill": fill}, + kh=kh, + bolt={ + "force": bolt["force"], + "trend": (slope["dip_direction"] + 180.0) % 360.0, + "plunge": bolt["plunge"], + }, + ) + length = r["line_length"] if r else None + short = any( + j.get("persistence_m") is not None + and length is not None + and j["persistence_m"] < length + for j in (a, b) + ) + out.append( + { + "mode": "wedge", + "ids": list(item["ids"]), + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "sliding": r["mode"] if r else None, + "W": _fmt(r["W"]) if r else None, + "volume": _fmt(r["volume"]) if r else None, + } + ) + return out + + +def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]]: + """암 측점 × 깎기 쪽 — 사면 · 절리 · Markland · 안전율 · 판정.""" + settings = joints_doc["settings"] + sets = joints_doc["sets"] + materials = {m["id"]: m for m in ground.get("materials") or []} + resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + seismic = ground.get("seismic") or {} + factors = ground.get("safety_factors") or {} + conds = [("dry", 0.0, 0.0), ("wet", float(settings["water_fill_wet"]), 0.0)] + if seismic.get("enabled") and seismic.get("kh"): + conds.append(("seismic", 0.0, float(seismic["kh"]))) + out = [] + for station in rock_stations(source): + ch = float(station["chainage_m"]) + key = chainage_key(ch) + joints = [j for j in sets if j["from_m"] <= ch <= j["to_m"]] + cross = src.cross_section(source, ch) + gamma = _gamma_for(resolved.get(key), materials, settings.get("gamma_r")) + for slope in station_slopes(cross, cross["design"]): + kin = engine.kinematic( + slope, joints, settings["lateral_planar_deg"], settings["lateral_toppling_deg"] + ) + cases = _cases_for(slope, joints, kin, settings, gamma, conds) + for case in cases: + need = (factors.get(case["condition"]) or {}).get("cut") + case["required"] = need + case["ok"] = None if case["F"] is None or need is None else case["F"] >= need + out.append( + { + "key": key, + "chainage_m": ch, + "label": station.get("label"), + "ground_type": station.get("ground_type"), + "slope": slope, + "gamma_r": gamma, + "joints": [ + {k: j[k] for k in ("id", "name", "dip_direction", "dip", "phi", "c")} + for j in joints + ], + "kinematic": kin, + "cases": cases, + **_verdict(kin, cases), + } + ) + return out + + +def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]: + """판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음).""" + values = [c["F"] for c in cases if c["F"] is not None] + min_f = min(values) if values else None + if any(c["ok"] is False for c in cases): + verdict = "NG" + elif kin["toppling"] or (cases and not values): + verdict = "check" + elif cases: + verdict = "OK" + else: + verdict = "none" + return { + "counts": { + "planar": len(kin["planar"]), + "wedge": len(kin["wedge"]), + "toppling": len(kin["toppling"]), + }, + "min_F": min_f, + "verdict": verdict, + } + + +def station_net(entry: dict, settings: dict) -> dict[str, Any]: + """한 측점 쪽 스테레오넷(해석 때 절리 그대로) — 두 투영 모두(화면이 고름).""" + joints = entry["joints"] + phi = min((j["phi"] for j in joints), default=NEW_SET["phi"]) + nets = { + kind: stereonet( + entry["slope"], + joints, + phi, + kind, + settings["lateral_planar_deg"], + settings["lateral_toppling_deg"], + ) + for kind in PROJECTIONS + } + return {**nets, "phi": phi} diff --git a/E06_StabRock/E06_StabRock_Router.py b/E06_StabRock/E06_StabRock_Router.py new file mode 100644 index 000000000..559e41d2d --- /dev/null +++ b/E06_StabRock/E06_StabRock_Router.py @@ -0,0 +1,204 @@ +"""E06 암반 평사투영 해석 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1). + +- `GET /api/projects/{id}/stab/rock` — 절리 작업본(없으면 초기값) · 범위 · 암 측점 · 고칠 수 있는지 +- `PUT …/stab/rock` — [저장] 검사 뒤 작업본 `E06_StabRock/joints.json` +- `POST …/stab/rock/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) +- `POST …/stab/rock/analyze` — [해석] 암 측점 × 깎기 쪽 · 지문이 같으면 안 셈(`fresh`) +- `GET …/stab/rock/result` — 측점별 모드 · 안전율 · 판정(+ 지문이 지금과 다른지) +- `GET …/stab/rock/result?station=&side=` — 그 측점 쪽 + 스테레오넷(등각 · 등면적) + +입력 = E01 복사본 + E02 지반 조건(물성 · 지진 · 기준안전율) + 절리 작업본(저장 안 한 캐시는 안 씀). +계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab_rock/<지문>`. +공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from common_util.common_util_json import atomic_write_json +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Router as e02 +from E06_StabRock import E06_StabRock_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E06 Stab Rock"]) + +KIND = "stab_rock" + + +class JointsRequest(BaseModel): + joints: dict[str, Any] + + +def ensure_initial(project_root: Path) -> dict[str, Any]: + """초기값이 없을 때만 한 번 — 절리 없음 + 기본 설정.""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + doc = model.build_initial() + atomic_write_json(path, doc) + return doc + + +def _working(project_root: Path) -> dict[str, Any] | None: + path = model.joints_path(project_root) + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _joints(project_root: Path) -> dict[str, Any]: + return model.upgrade(_working(project_root) or ensure_initial(project_root)) + + +def _source(project_root: Path) -> Path: + root = src.source_dir(project_root) + if not (root / src.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.") + return root + + +def _fingerprint(project_root: Path, source: Path) -> tuple[str, dict, dict]: + joints = _joints(project_root) + ground = e02._read_working(project_root) or e02.ensure_initial(project_root) + fp = derived_key( + model.engine_hash(), + src.manifest(source)["source_fingerprint"], + { + "sets": joints["sets"], + "settings": {k: v for k, v in joints["settings"].items() if k != "projection"}, + }, + model._ground_inputs(ground), + ) + return fp, joints, ground + + +def _latest(project_root: Path) -> dict[str, Any] | None: + """가장 최근 저장본(지문이 달라도 — 화면이 「다시 해석」 띠를 띄움).""" + folder = derived_dir(project_root, KIND) + files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True) + for path in files: + saved = read_derived(project_root, KIND, path.stem) + if saved: + return saved + return None + + +@router.get("/{project_id}/stab/rock") +async def get_rock( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, project = await e02._project(project_id) + initial = ensure_initial(root) + working = _working(root) + try: + stations = [ + {"chainage_m": s["chainage_m"], "label": s["label"], "ground_type": s["ground_type"]} + for s in model.rock_stations(_source(root)) + ] + except HTTPException: + stations = [] + return { + "joints": model.upgrade(working or initial), + "saved": working is not None, + "defaults": { + "settings": model.DEFAULT_SETTINGS, + "new_set": model.NEW_SET, + "limits": model.LIMITS, + }, + "stations": stations, + "editable": e02._editable(session, project), + } + + +@router.put("/{project_id}/stab/rock") +async def put_rock(project_id: UUID, payload: JointsRequest) -> dict[str, Any]: + root, _ = await e02._project(project_id) + errors = model.check_joints(payload.joints) + if errors: + raise HTTPException(status_code=422, detail={"errors": errors}) + doc = model.clean(payload.joints) + atomic_write_json(model.joints_path(root), doc) + return {"joints": doc} + + +@router.post("/{project_id}/stab/rock/reset") +async def reset_rock(project_id: UUID) -> dict[str, Any]: + root, _ = await e02._project(project_id) + initial = ensure_initial(root) + atomic_write_json(model.joints_path(root), initial) + return {"joints": model.upgrade(initial)} + + +def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]: + verdicts: dict[str, int] = {} + for item in stations: + verdicts[item["verdict"]] = verdicts.get(item["verdict"], 0) + 1 + return { + "slopes": len(stations), + "verdicts": verdicts, + "modes": { + mode: sum(item["counts"][mode] for item in stations) + for mode in ("planar", "wedge", "toppling") + }, + } + + +def _run(root: Path) -> dict[str, Any]: + source = _source(root) + fp, joints, ground = _fingerprint(root, source) + if read_derived(root, KIND, fp): + return {"status": "fresh", "fingerprint": fp} + stations = model.analyze(source, joints, ground) + value = { + "fingerprint": fp, + "settings": joints["settings"], + "summary": _summary(stations), + "stations": stations, + } + write_derived(root, KIND, fp, value) + return {"status": "done", "fingerprint": fp, "summary": value["summary"]} + + +@router.post("/{project_id}/stab/rock/analyze") +async def analyze_rock(project_id: UUID) -> dict[str, Any]: + """[해석] — 암 측점 수십 개 · 1 초 안쪽이라 바로 셈(진행률 없음).""" + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_run, root) + + +def _result(root: Path, station: str | None, side: str | None) -> dict[str, Any]: + source = _source(root) + fp, _, _ = _fingerprint(root, source) + saved = read_derived(root, KIND, fp) or _latest(root) + if not saved: + return {"status": "empty", "stale": False, "result": None} + value = saved["value"] + stale = value["fingerprint"] != fp + if station is None: + return {"status": "success", "stale": stale, "result": value} + for entry in value["stations"]: + if entry["key"] == station and (side is None or entry["slope"]["side"] == side): + net = model.station_net(entry, value["settings"]) + return {"status": "success", "stale": stale, "entry": entry, "stereonet": net} + raise HTTPException(status_code=404, detail="그 측점의 암반 해석 결과가 없습니다.") + + +@router.get("/{project_id}/stab/rock/result") +async def result_rock( + project_id: UUID, station: str | None = None, side: str | None = None +) -> dict[str, Any]: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_result, root, station, side) diff --git a/E06_StabRock/E06_StabRock_Stereonet.py b/E06_StabRock/E06_StabRock_Stereonet.py new file mode 100644 index 000000000..fb0b3cd3e --- /dev/null +++ b/E06_StabRock/E06_StabRock_Stereonet.py @@ -0,0 +1,137 @@ +"""E06 스테레오넷 투영 좌표 — 하반구 · 등각(Wulff) · 등면적(Schmidt) (PLAN 536-1). + +화면(SVG)은 이 좌표를 그리기만 함 — 반지름 1 원 · x = 동 · y = 북(화면은 y 를 뒤집음). +극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역(Markland 부등식과 같은 경계 — +`E06_StabRock_Engine.kinematic`)을 냄. 투영식 = 표준 구면 투영(mplstereonet 등은 대조만). +""" + +from __future__ import annotations + +import math +from typing import Any + +from E06_StabRock.E06_StabRock_Engine import ( + LATERAL_PLANAR_DEG, + LATERAL_TOPPLING_DEG, + apparent_dip, + intersection, + pole, +) + +PROJECTIONS = ("equal_angle", "equal_area") +STEP_DEG = 2.0 # 곡선 표본 간격 + + +def radius(plunge: float, kind: str) -> float: + """선주각 → 중심에서 거리(0 = 연직 · 1 = 수평).""" + half = math.radians(90.0 - plunge) / 2.0 + return math.tan(half) if kind == "equal_angle" else math.sqrt(2.0) * math.sin(half) + + +def project(trend: float, plunge: float, kind: str) -> list[float]: + r = radius(plunge, kind) + t = math.radians(trend) + return [round(r * math.sin(t), 5), round(r * math.cos(t), 5)] + + +def inverse(x: float, y: float, kind: str) -> tuple[float, float]: + """투영 좌표 → (추향 · 선주각) — 왕복 시험 · 화면 마우스 자리 읽기.""" + r = math.hypot(x, y) + half = math.atan(r) if kind == "equal_angle" else math.asin(min(1.0, r / math.sqrt(2.0))) + return math.degrees(math.atan2(x, y)) % 360.0, 90.0 - math.degrees(2.0 * half) + + +def _samples(lo: float, hi: float) -> list[float]: + n = max(2, int(math.ceil((hi - lo) / STEP_DEG)) + 1) + return [lo + (hi - lo) * k / (n - 1) for k in range(n)] + + +def great_circle(dip_dir: float, dip: float, kind: str) -> list[list[float]]: + """면의 대원 — 주향 한끝 → 경사 방향 → 다른 끝.""" + return [ + project(dip_dir + b, max(0.0, apparent_dip(dip, dip_dir, dip_dir + b)), kind) + for b in _samples(-90.0, 90.0) + ] + + +def _zone(trend_lo, trend_hi, plunge_lo, plunge_hi, kind) -> list[list[float]] | None: + """(추향 · 선주각) 띠 → 닫힌 다각형 — plunge_lo/hi 는 추향의 함수 · 비면 None.""" + ts = _samples(trend_lo, trend_hi) + lows = [plunge_lo(t) for t in ts] + highs = [plunge_hi(t) for t in ts] + keep = [k for k in range(len(ts)) if highs[k] > lows[k]] + if len(keep) < 2: + return None + outer = [project(ts[k], lows[k], kind) for k in keep] + inner = [project(ts[k], highs[k], kind) for k in reversed(keep)] + return outer + inner + + +def stereonet( + slope: dict, + joints: list[dict], + phi: float, + kind: str = "equal_angle", + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, Any]: + """slope = {dip_direction, dip} · joints = [{id, dip_direction, dip}] · phi = 영역 마찰각.""" + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + + def face_app(t: float) -> float: + return apparent_dip(pf, af, t) + + planes = [] + for j in joints: + tr, pl = pole(j["dip_direction"], j["dip"]) + planes.append( + { + "id": j["id"], + "pole": project(tr, pl, kind), + "great_circle": great_circle(j["dip_direction"], j["dip"], kind), + } + ) + lines = [] + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is not None: + lines.append({"ids": [a["id"], b["id"]], "point": project(*line, kind)}) + # 일광 엔벨로프(극점 꼴) = 사면 겉보기 경사로 기운 면들의 극점 + envelope = [ + project(af + b + 180.0, 90.0 - face_app(af + b), kind) for b in _samples(-90.0, 90.0) + ] + zones = { + # 평면(극점) — 극점 추향 αf + 180 ± 측방 · 선주각 90 − ψapp ~ 90 − φ + "planar": _zone( + af + 180.0 - lateral_planar, + af + 180.0 + lateral_planar, + lambda t: 90.0 - face_app(t - 180.0), + lambda t: 90.0 - phi, + kind, + ), + # 쐐기(교선) — 추향 αf ± 90 · 선주각 φ ~ ψapp + "wedge": _zone(af - 90.0, af + 90.0, lambda t: phi, face_app, kind), + # 전도(극점) — 극점 추향 αf ± 측방 · 선주각 0 ~ ψf − φ + "toppling": _zone( + af - lateral_toppling, + af + lateral_toppling, + lambda t: 0.0, + lambda t: pf - phi, + kind, + ), + } + return { + "projection": kind, + "slope": { + "pole": project(*pole(af, pf), kind), + "great_circle": great_circle(af, pf, kind), + }, + "planes": planes, + "intersections": lines, + # 마찰원 — 극점 꼴 = 중심에서 φ(선주각 90 − φ) · 교선 꼴 = 선주각 φ + "friction_pole_r": round(radius(90.0 - phi, kind), 5), + "friction_line_r": round(radius(phi, kind), 5), + "daylight_envelope": envelope, + "zones": zones, + } diff --git a/E06_StabRock/E06_StabRock_UI_Joints.ts b/E06_StabRock/E06_StabRock_UI_Joints.ts new file mode 100644 index 000000000..6350ae45f --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Joints.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * E06_StabRock_UI_Joints.ts + * E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정 + * (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange — + * 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글). + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export interface JointSet { + id: string; + name: string; + from_m: number; + to_m: number; + dip_direction: number; + dip: number; + phi: number; + c: number; + persistence_m: number | null; +} + +export type Projection = "equal_angle" | "equal_area"; + +export interface RockSettings { + projection: Projection; + lateral_planar_deg: number; + lateral_toppling_deg: number; + gamma_r: number | null; + water_fill_wet: number; + crack: { mode: "none" | "critical" | "depth"; z: number | null }; + bolt: { force: number; plunge: number }; +} + +export interface JointsDoc { + version: number; + sets: JointSet[]; + settings: RockSettings; +} + +export interface RockDefaults { + settings: RockSettings; + new_set: { phi: number; c: number }; + limits: Record; +} + +export interface JointsView { + doc: JointsDoc; + defaults: RockDefaults; + editable: boolean; + endM: number; // 마지막 암 측점(새 줄 구간 끝) + change: () => void; // 값만 바뀜 + rebuild: () => void; // 줄 수가 바뀜 +} + +/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */ +function numberInput( + view: JointsView, + value: number | null, + limit: string, + set: (v: number | null) => void, + opts: { optional?: boolean; step?: string; path: string }, +): HTMLInputElement { + const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; + const input = el("input", { + className: "e06-input", + attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, + }) as HTMLInputElement; + input.value = value === null ? "" : String(value); + input.disabled = !view.editable; + const check = (): boolean => { + const raw = input.value.trim(); + const num = Number(raw); + const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; + input.classList.toggle("is-invalid", !ok); + input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; + return ok; + }; + input.addEventListener("input", () => { + const raw = input.value.trim(); + set(raw === "" ? null : Number(raw)); + check(); + view.change(); + }); + check(); + return input; +} + +function nextId(sets: JointSet[]): string { + let k = sets.length + 1; + while (sets.some((s) => s.id === `J${k}`)) k += 1; + return `J${k}`; +} + +const COLUMNS: LocaleKey[] = [ + "E06_Col_Name", + "E06_Col_From", + "E06_Col_To", + "E06_Col_DipDir", + "E06_Col_Dip", + "E06_Col_Phi", + "E06_Col_C", + "E06_Col_Persist", +]; + +function jointTable(view: JointsView): HTMLElement { + const wrap = el("div", { className: "e06-table-wrap" }); + const table = el("table", { className: "e06-table" }); + const head = el("tr"); + for (const key of COLUMNS) head.append(el("th", { text: t(key) })); + head.append(el("th")); + table.append(el("thead"), el("tbody")); + table.tHead!.append(head); + view.doc.sets.forEach((row, k) => { + const tr = el("tr", { attrs: { "data-id": row.id } }); + const name = el("input", { + className: "e06-input e06-input--name", + attrs: { type: "text", "data-path": `sets.${k}.name` }, + }) as HTMLInputElement; + name.value = row.name; + name.disabled = !view.editable; + name.addEventListener("input", () => { + row.name = name.value; + name.classList.toggle("is-invalid", !name.value.trim()); + view.change(); + }); + tr.append(el("td", { children: [name] })); + const fields: [keyof JointSet, string, boolean][] = [ + ["from_m", "chainage_m", false], + ["to_m", "chainage_m", false], + ["dip_direction", "dip_direction", false], + ["dip", "dip", false], + ["phi", "phi", false], + ["c", "c", false], + ["persistence_m", "persistence_m", true], + ]; + for (const [key, limit, optional] of fields) { + const input = numberInput( + view, + row[key] as number | null, + limit, + (v) => ((row as unknown as Record)[key] = v), + { optional, path: `sets.${k}.${key}` }, + ); + tr.append(el("td", { children: [input] })); + } + const remove = createButton({ + label: "×", + variant: "danger-ghost", + size: "sm", + onClick: () => { + view.doc.sets.splice(k, 1); + view.rebuild(); + }, + }); + remove.title = t("E06_Remove"); + remove.disabled = !view.editable; + tr.append(el("td", { children: [remove] })); + table.tBodies[0].append(tr); + }); + wrap.append(table); + if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); + return wrap; +} + +function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { + const row = el("label", { className: "e06-setting" }); + row.append(el("span", { text: t(label) }), control); + return row; +} + +function select( + view: JointsView, + value: T, + options: [T, LocaleKey][], + set: (v: T) => void, + path: string, +): HTMLSelectElement { + const box = el("select", { + className: "e06-input", + attrs: { "data-path": path }, + }) as HTMLSelectElement; + for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); + box.value = value; + box.disabled = !view.editable; + box.addEventListener("change", () => { + set(box.value as T); + view.rebuild(); + }); + return box; +} + +export function buildJointCards(view: JointsView): HTMLElement[] { + const s = view.doc.settings; + const setsCard = createPanelCard(t("E06_Card_Joints"), false); + const add = createButton({ + label: t("E06_Add"), + variant: "ghost", + size: "sm", + onClick: () => { + const id = nextId(view.doc.sets); + view.doc.sets.push({ + id, + name: id, + from_m: 0, + to_m: Math.ceil(view.endM), + dip_direction: 0, + dip: 45, + phi: view.defaults.new_set.phi, + c: view.defaults.new_set.c, + persistence_m: null, + }); + view.rebuild(); + }, + }); + add.disabled = !view.editable; + setsCard.append( + el("p", { className: "e06-hint", text: t("E06_JointsHint") }), + jointTable(view), + el("div", { className: "e06-row-actions", children: [add] }), + ); + + const setCard = createPanelCard(t("E06_Card_Settings"), true); + const crack = s.crack; + setCard.append( + settingRow( + "E06_Set_LateralPlanar", + numberInput( + view, + s.lateral_planar_deg, + "lateral_deg", + (v) => (s.lateral_planar_deg = v ?? NaN), + { + path: "settings.lateral_planar_deg", + }, + ), + ), + settingRow( + "E06_Set_LateralTopple", + numberInput( + view, + s.lateral_toppling_deg, + "lateral_deg", + (v) => (s.lateral_toppling_deg = v ?? NaN), + { path: "settings.lateral_toppling_deg" }, + ), + ), + settingRow( + "E06_Set_Gamma", + numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), { + optional: true, + path: "settings.gamma_r", + }), + ), + settingRow( + "E06_Set_WaterWet", + numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), { + step: "0.05", + path: "settings.water_fill_wet", + }), + ), + settingRow( + "E06_Set_Crack", + select( + view, + crack.mode, + [ + ["none", "E06_Crack_None"], + ["critical", "E06_Crack_Critical"], + ["depth", "E06_Crack_Depth"], + ], + (v) => { + crack.mode = v; + crack.z = v === "depth" ? (crack.z ?? 1) : null; + }, + "settings.crack.mode", + ), + ), + ); + if (crack.mode === "depth") + setCard.append( + settingRow( + "E06_Set_CrackZ", + numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }), + ), + ); + setCard.append( + settingRow( + "E06_Set_BoltForce", + numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), { + path: "settings.bolt.force", + }), + ), + settingRow( + "E06_Set_BoltPlunge", + numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), { + path: "settings.bolt.plunge", + }), + ), + el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), + ); + return [setsCard, setCard]; +} diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts new file mode 100644 index 000000000..f96156bd7 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -0,0 +1,397 @@ +/* ============================================================================= + * 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. + * ========================================================================== */ + +import "./E06_StabRock_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { + buildJointCards, + type JointsDoc, + type JointsView, + type Projection, + type RockDefaults, +} from "./E06_StabRock_UI_Joints"; +import { + buildBand, + buildDetail, + buildList, + entryId, + type RockResult, + type StationEntry, +} from "./E06_StabRock_UI_Result"; +import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet"; + +interface RockResponse { + joints: JointsDoc; + saved?: boolean; + defaults?: RockDefaults; + stations?: { chainage_m: number; label: string; ground_type: string }[]; + editable?: boolean; +} + +interface ResultResponse { + status: string; + stale: boolean; + result: RockResult | null; +} + +interface StationResponse { + entry: StationEntry; + stereonet: Record & { phi: number }; +} + +const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`; + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + if (typeof detail === "string") throw new Error(detail); + const errors = (detail as { errors?: unknown[] } | undefined)?.errors; + throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`); + } + return body; +} + +function toggle(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) { + const group = el("div", { className: "e06-toggle" }); + const buttons = options.map(([key, v]) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = v; + button.classList.toggle("is-on", v === value); + button.addEventListener("click", () => { + buttons.forEach((b) => b.classList.toggle("is-on", b === button)); + onPick(v); + }); + return button; + }); + group.append(...buttons); + return group; +} + +export function renderE06StabRock(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); + const main = el("div", { className: "e06-page" }); + mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") })); + + // ---- 좌 ---- + const cards = el("div", { className: "e06-cards" }); + const status = el("p", { 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", + onClick: () => void reset(), + }); + const analyzeButton = createButton({ + label: t("E06_Analyze"), + variant: "filled", + onClick: () => void analyze(), + }); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(resetButton, saveButton, analyzeButton); + panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions); + attachCollapsible(cards); + + // ---- 가운데 · 우 ---- + const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") }); + staleBar.hidden = true; + const bandSlot = el("div"); + const title = el("strong", { className: "e06-title" }); + const netSlot = el("div", { className: "e06-net-slot" }); + const legendSlot = el("div"); + let projection: Projection = "equal_angle"; + let netMode: NetMode = "all"; + const toolbar = el("div", { className: "e06-toolbar" }); + const projectionToggle = el("div"); + toolbar.append( + title, + projectionToggle, + toggle( + [ + ["E06_View_All", "all"], + ["E06_Mode_planar", "planar"], + ["E06_Mode_wedge", "wedge"], + ["E06_Mode_toppling", "toppling"], + ], + netMode, + (v) => { + netMode = v; + paintNet(); + }, + ), + ); + const stage = el("div", { className: "e06-stage" }); + stage.append(netSlot, legendSlot); + const center = el("div", { className: "e06-center" }); + center.append(staleBar, bandSlot, toolbar, stage); + const listSlot = el("div", { className: "e06-list-slot" }); + const detailSlot = el("div"); + const sideBody = el("div", { className: "e06-side" }); + sideBody.append(listSlot, detailSlot); + const side = createSidePanels({ + prefKey: "e06-stab-rock-result", + panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e06-row" }); + row.append(center, side.root); + main.append(row); + + // ---- 상태 ---- + let view: JointsView | null = null; + let dirty = false; + let result: RockResult | null = null; + let active: string | null = null; + let shown: StationResponse | null = null; + + const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length; + const paintStatus = (): void => { + const lines: string[] = []; + if (view && !view.editable) lines.push(t("E06_ReadOnly")); + if (dirty) lines.push(t("E06_Unsaved")); + const bad = invalidCount(); + if (bad) lines.push(`${t("E06_Invalid")} ${bad}`); + status.textContent = lines.join("\n"); + status.classList.toggle("is-error", bad > 0); + }; + + const renderCards = (): void => { + if (!view) return; + // 다시 그려도 접힘은 그대로 + const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed")); + const built = buildJointCards(view); + built.forEach( + (c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]), + ); + cards.replaceChildren(...built); + paintStatus(); + }; + + const remember = (): void => { + if (!view) return; + dirty = true; + sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc)); + }; + + function paintProjection(): void { + projectionToggle.replaceChildren( + toggle( + [ + ["E06_Proj_Angle", "equal_angle"], + ["E06_Proj_Area", "equal_area"], + ], + projection, + (v) => { + projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감) + paintNet(); + }, + ), + ); + } + + function paintNet(): void { + legendSlot.replaceChildren(netLegend(netMode)); + if (!shown) { + title.textContent = ""; + netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") })); + return; + } + const e = shown.entry; + title.textContent = `${e.label ?? e.key} · ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} · φ ${shown.stereonet.phi}°`; + netSlot.replaceChildren( + drawStereonet(shown.stereonet[projection], netMode, { + planar: e.kinematic.planar, + toppling: e.kinematic.toppling, + wedge: e.kinematic.wedge.map((w) => w.ids.join("|")), + }), + ); + } + + function paintResult(): void { + const stations = result?.stations ?? []; + bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id))); + listSlot.replaceChildren( + stations.length + ? buildList(stations, active, (id) => void select(id)) + : el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }), + ); + detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span")); + paintNet(); + } + + async function select(id: string): Promise { + active = id; + const [key, sideName] = id.split("|"); + try { + shown = await api( + `/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`, + ); + } catch (error) { + shown = null; + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + paintResult(); + } + + async function loadResult(): Promise { + const body = await api(`/projects/${projectId}/stab/rock/result`); + result = body.result; + staleBar.hidden = !body.stale; + const stations = result?.stations ?? []; + const keep = stations.find((e) => entryId(e) === active); + const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0]; + if (first) await select(entryId(first)); + else { + active = null; + shown = null; + paintResult(); + } + } + + const adopt = (body: RockResponse, keepCache: boolean): void => { + if (!view) return; + const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; + 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; + renderCards(); + }; + + async function putJoints(): Promise { + if (!view?.editable) return false; + if (invalidCount()) { + showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error"); + return false; + } + const body = await api(`/projects/${projectId}/stab/rock`, { + method: "PUT", + body: JSON.stringify({ joints: view.doc }), + }); + adopt(body, false); + 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( + t("E06_ResetConfirm"), + t("Common_Btn_Reset"), + t("Common_Btn_Cancel"), + ); + if (!ok) return; + showLoadingOverlay(); + try { + const body = await api(`/projects/${projectId}/stab/rock/reset`, { + method: "POST", + }); + adopt(body, false); + showToast(t("E06_ResetDone"), "success"); + await loadResult(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function analyze(): Promise { + if (!view?.editable) return; + analyzeButton.disabled = true; + status.textContent = t("E06_Running"); + try { + if (dirty && !(await putJoints())) return; + const body = await api<{ status: string; summary?: RockResult["summary"] }>( + `/projects/${projectId}/stab/rock/analyze`, + { method: "POST" }, + ); + await loadResult(); + showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + analyzeButton.disabled = !view.editable; + paintStatus(); + } + } + + void (async () => { + try { + const body = await api(`/projects/${projectId}/stab/rock`); + const stations = body.stations ?? []; + projection = body.joints.settings.projection; + paintProjection(); + view = { + doc: body.joints, + defaults: body.defaults as RockDefaults, + editable: body.editable ?? false, + endM: stations.length ? stations[stations.length - 1].chainage_m : 0, + change: () => { + remember(); + paintStatus(); + }, + rebuild: () => { + remember(); + renderCards(); + }, + }; + adopt(body, true); + await loadResult(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e06-empty", + text: error instanceof Error ? error.message : t("Common_Status_Error"), + }), + ); + } + })(); +} diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts new file mode 100644 index 000000000..a2d3352f8 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * E06_StabRock_UI_Result.ts + * E06 결과 — 위험 띠(누가 축 · 측점 판정 색 · 누르면 그 측점) · 우측 측점 목록(쪽 · 모드 수 · + * 최소 안전율 · 판정) · 고른 측점 사면 · 모드별 안전율 표. 값은 서버 저장본 그대로(계산 없음). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export type Verdict = "NG" | "check" | "OK" | "none"; + +export interface RockCase { + mode: "planar" | "wedge"; + ids: string[]; + condition: "dry" | "wet" | "seismic"; + F: number | null; + formed: boolean; + persistence_short: boolean; + required: number | null; + ok: boolean | null; + sliding?: string | null; +} + +export interface StationEntry { + key: string; + chainage_m: number; + label: string | null; + ground_type: string; + slope: { side: string; dip_direction: number; dip: number; height_m: number; upper_dip: number }; + gamma_r: number; + joints: { + id: string; + name: string; + dip_direction: number; + dip: number; + phi: number; + c: number; + }[]; + kinematic: { + planar: string[]; + toppling: string[]; + wedge: { ids: [string, string]; trend: number; plunge: number }[]; + }; + cases: RockCase[]; + counts: { planar: number; wedge: number; toppling: number }; + min_F: number | null; + verdict: Verdict; +} + +export interface RockResult { + fingerprint: string; + summary: { + slopes: number; + verdicts: Partial>; + modes: Record; + }; + stations: StationEntry[]; +} + +const ORDER: Verdict[] = ["NG", "check", "OK", "none"]; +export const entryId = (e: StationEntry): string => `${e.key}|${e.slope.side}`; +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined ? "–" : v.toFixed(d); + +function badge(verdict: Verdict): HTMLElement { + return el("span", { + className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`, + text: t(`E06_V_${verdict}` as LocaleKey), + }); +} + +/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */ +export function buildBand( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const box = el("div", { className: "e06-band", attrs: { "data-role": "band" } }); + const worst = new Map(); + for (const e of stations) { + const prev = worst.get(e.chainage_m); + if (!prev || ORDER.indexOf(e.verdict) < ORDER.indexOf(prev.verdict)) worst.set(e.chainage_m, e); + } + const list = [...worst.values()].sort((a, b) => a.chainage_m - b.chainage_m); + const legend = el("div", { className: "e06-band__legend" }); + legend.append(el("strong", { text: t("E06_Band") })); + for (const v of ORDER) { + const n = list.filter((e) => e.verdict === v).length; + if (n) legend.append(badge(v), el("span", { className: "e06-band__count", text: String(n) })); + } + const strip = el("div", { className: "e06-band__strip" }); + if (list.length) { + const lo = list[0].chainage_m; + const hi = Math.max(list[list.length - 1].chainage_m, lo + 1); + list.forEach((e, i) => { + const a = i ? (list[i - 1].chainage_m + e.chainage_m) / 2 : lo; + const b = i < list.length - 1 ? (e.chainage_m + list[i + 1].chainage_m) / 2 : hi; + const seg = el("button", { + className: `e06-band__seg e06-band__seg--${e.verdict.toLowerCase()}${ + active?.startsWith(`${e.key}|`) ? " is-active" : "" + }`, + attrs: { + type: "button", + "data-key": e.key, + title: `${e.label ?? e.key} · ${t(`E06_V_${e.verdict}` as LocaleKey)}`, + }, + }); + seg.style.left = `${((a - lo) / (hi - lo)) * 100}%`; + seg.style.width = `${Math.max(0.4, ((b - a) / (hi - lo)) * 100)}%`; + seg.addEventListener("click", () => onSelect(entryId(e))); + strip.append(seg); + }); + const ticks = el("div", { className: "e06-band__ticks" }); + ticks.append( + el("span", { text: `${lo.toFixed(0)} m` }), + el("span", { text: `${hi.toFixed(0)} m` }), + ); + box.append(legend, strip, ticks); + } else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") })); + return box; +} + +export function buildList( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } }); + const head = el("tr"); + for (const key of [ + "E06_L_Station", + "E06_L_Side", + "E06_L_Modes", + "E06_L_MinF", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const e of stations) { + const id = entryId(e); + const tr = el("tr", { + className: id === active ? "is-active" : "", + attrs: { "data-id": id, "data-verdict": e.verdict }, + }); + tr.append( + el("td", { text: e.label ?? e.key }), + el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }), + el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }), + el("td", { text: fmt(e.min_F) }), + el("td", { children: [badge(e.verdict)] }), + ); + tr.addEventListener("click", () => onSelect(id)); + table.tBodies[0].append(tr); + } + return table; +} + +export function buildDetail(e: StationEntry): HTMLElement { + const box = el("div", { className: "e06-detail", attrs: { "data-role": "detail" } }); + const s = e.slope; + box.append( + el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }), + el("p", { + className: "e06-hint", + text: t("E06_SlopeLine") + .replace("{dd}", s.dip_direction.toFixed(0)) + .replace("{dip}", s.dip.toFixed(1)) + .replace("{h}", s.height_m.toFixed(2)) + .replace("{up}", s.upper_dip.toFixed(1)) + .replace("{g}", e.gamma_r.toFixed(1)), + }), + ); + const kin = e.kinematic; + const kinText = [ + `${t("E06_Mode_planar")} ${kin.planar.join(", ") || "–"}`, + `${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`, + `${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`, + ]; + box.append(el("p", { className: "e06-kin", text: kinText.join("\n") })); + if (!e.cases.length) { + box.append( + el("p", { + className: "e06-hint", + text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"), + }), + ); + return box; + } + const table = el("table", { className: "e06-cases" }); + const head = el("tr"); + for (const key of [ + "E06_C_Mode", + "E06_C_Joints", + "E06_C_Cond", + "E06_C_F", + "E06_C_Req", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const c of e.cases) { + const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; + const note = !c.formed ? t("E06_NotFormed") : c.persistence_short ? t("E06_Persist") : ""; + const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } }); + tr.append( + el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }), + el("td", { text: c.ids.join("·") }), + el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }), + el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }), + el("td", { text: fmt(c.required) }), + el("td", { children: [badge(verdict)] }), + ); + table.tBodies[0].append(tr); + } + box.append(table); + if (kin.toppling.length) + box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") })); + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Stereonet.ts b/E06_StabRock/E06_StabRock_UI_Stereonet.ts new file mode 100644 index 000000000..73e2b9e21 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Stereonet.ts @@ -0,0 +1,193 @@ +/* ============================================================================= + * E06_StabRock_UI_Stereonet.ts + * E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를 + * 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도. + * 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 · + * 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const SIZE = 440; +const R = 190; +const C = SIZE / 2; + +export type NetMode = "all" | "planar" | "wedge" | "toppling"; +type XY = [number, number]; + +export interface NetData { + projection: string; + slope: { pole: XY; great_circle: XY[] }; + planes: { id: string; pole: XY; great_circle: XY[] }[]; + intersections: { ids: [string, string]; point: XY }[]; + friction_pole_r: number; + friction_line_r: number; + daylight_envelope: XY[]; + zones: Record<"planar" | "wedge" | "toppling", XY[] | null>; +} + +export interface NetMarks { + planar: string[]; + toppling: string[]; + wedge: string[]; // "J1|J2" +} + +function svg( + tag: K, + attrs: Record = {}, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +const sx = (p: XY): number => C + R * p[0]; +const sy = (p: XY): number => C - R * p[1]; +const path = (pts: XY[], close = false): string => + pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") + + (close ? "Z" : ""); + +/** 절리 색 번호(0~5) — 표 · 그림 같은 차례 */ +export const jointTone = (index: number): number => index % 6; + +export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement { + const root = svg("svg", { + viewBox: `0 0 ${SIZE} ${SIZE}`, + class: "e06-net", + "data-role": "stereonet", + "data-projection": net.projection, + "data-mode": mode, + }); + const clip = svg("clipPath", { id: "e06-net-clip" }); + clip.append(svg("circle", { cx: C, cy: C, r: R })); + const defs = svg("defs"); + defs.append(clip); + root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" })); + const body = svg("g", { "clip-path": "url(#e06-net-clip)" }); + root.append(body); + + const show = (m: Exclude): boolean => mode === "all" || mode === m; + for (const m of ["planar", "wedge", "toppling"] as const) { + const zone = net.zones[m]; + if (zone && show(m)) + body.append( + svg("path", { + d: path(zone, true), + class: `e06-net__zone e06-net__zone--${m}`, + "data-zone": m, + }), + ); + } + // 마찰원 + if (show("planar") || show("toppling")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_pole_r, + class: "e06-net__friction", + "data-role": "friction-pole", + }), + ); + if (show("wedge")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_line_r, + class: "e06-net__friction e06-net__friction--line", + "data-role": "friction-line", + }), + ); + if (show("planar")) + body.append( + svg("path", { + d: path(net.daylight_envelope), + class: "e06-net__envelope", + "data-role": "daylight", + }), + ); + // 대원 + net.planes.forEach((p, i) => + body.append( + svg("path", { + d: path(p.great_circle), + class: `e06-net__gc e06-tone-${jointTone(i)}`, + "data-id": p.id, + }), + ), + ); + body.append( + svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }), + ); + + // 극점(평면 · 전도 보기) · 교선(쐐기 보기) + if (mode !== "wedge") + net.planes.forEach((p, i) => { + const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id); + const g = svg("g", { + class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`, + "data-pole": p.id, + }); + g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 })); + g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id)); + root.append(g); + }); + if (mode === "all" || mode === "wedge") + for (const line of net.intersections) { + const key = line.ids.join("|"); + const risky = marks.wedge.includes(key); + const [x, y] = [sx(line.point), sy(line.point)]; + const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key }); + g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` })); + g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("·"))); + root.append(g); + } + // 사면 극점 · 방위 + root.append( + svg("rect", { + x: sx(net.slope.pole) - 4, + y: sy(net.slope.pole) - 4, + width: 8, + height: 8, + class: "e06-net__slope-pole", + }), + ); + for (const [label, x, y] of [ + ["N", C, C - R - 8], + ["E", C + R + 10, C + 4], + ["S", C, C + R + 18], + ["W", C - R - 10, C + 4], + ] as const) + root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label)); + root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" })); + root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" })); + return root; +} + +export function netLegend(mode: NetMode): HTMLElement { + const box = el("div", { className: "e06-legend" }); + const items: [string, LocaleKey][] = [ + ["slope", "E06_Lg_Slope"], + ["friction", "E06_Lg_Friction"], + ]; + if (mode === "all" || mode === "planar") + items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]); + if (mode === "all" || mode === "wedge") + items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]); + if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]); + if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]); + for (const [cls, key] of items) { + const row = el("span", { className: "e06-legend__row" }); + row.append( + el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), + el("span", { text: t(key) }), + ); + box.append(row); + } + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Style.css b/E06_StabRock/E06_StabRock_UI_Style.css new file mode 100644 index 000000000..47b4a8b49 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Style.css @@ -0,0 +1,485 @@ +/* ============================================================================= + * E06_StabRock_UI_Style.css + * E06 암반 해석 — 좌측 절리 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우측 측점 결과 + * 색은 테마 토큰(밝음 · 어두움 같이) · 판정 · 모드 · 절리 색만 고정색(E03 판정색과 같음) + * ========================================================================== */ + +.e06-page { + --e06-ng: #d03b3b; + --e06-ok: #0ca30c; + --e06-check: #e0a106; + --e06-none: #9a9a95; + --e06-planar: #d03b3b; + --e06-wedge: #f1963b; + --e06-toppling: #9b6dde; + height: 100dvh; + box-sizing: border-box; +} + +.e06-row { + height: 100%; +} + +.e06-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; + overflow: auto; +} + +.e06-empty, +.e06-hint, +.e06-status, +.e06-kin { + margin: 0; + white-space: pre-line; + color: var(--color-text-muted); + font-size: 12px; +} + +.e06-empty { + padding: var(--spacing-16); +} + +.e06-status.is-error { + color: var(--color-danger); +} + +.e06-kin { + color: var(--color-text); +} + +.e06-panel .ui-sidebar-actions { + flex-wrap: wrap; +} + +.e06-stale { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + color: var(--color-text); +} + +.e06-stale[hidden] { + display: none; +} + +/* ---- 좌측 표 · 설정 ---- */ +.e06-table-wrap { + overflow-x: auto; +} + +.e06-table { + border-collapse: collapse; + font-size: 12px; +} + +.e06-table th { + padding: 2px 3px; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.e06-table td { + padding: 1px 2px; +} + +.e06-input { + width: 4.2em; + padding: 2px 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 12px; + box-sizing: border-box; +} + +.e06-input--name { + width: 3.6em; +} + +select.e06-input { + width: auto; +} + +.e06-input.is-invalid { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface)); +} + +.e06-row-actions { + display: flex; + justify-content: flex-end; + margin-top: var(--spacing-4); +} + +.e06-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + margin: 2px 0; + font-size: 12px; + color: var(--color-text); +} + +.e06-setting .e06-input { + width: 5.5em; +} + +/* ---- 가운데 ---- */ +.e06-toolbar { + display: flex; + align-items: center; + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.e06-title { + color: var(--color-text); +} + +.e06-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e06-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e06-stage { + display: flex; + gap: var(--spacing-12); + align-items: flex-start; + flex-wrap: wrap; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.e06-net-slot { + flex: 1 1 360px; + max-width: 560px; +} + +.e06-net { + display: block; + width: 100%; + height: auto; + color: var(--color-text); + font-size: 11px; +} + +.e06-net__rim { + fill: var(--color-surface); + stroke: var(--color-text); + stroke-width: 1.4; +} + +.e06-net__zone { + stroke: none; + opacity: 0.28; +} + +.e06-net__zone--planar { + fill: var(--e06-planar); +} + +.e06-net__zone--wedge { + fill: var(--e06-wedge); +} + +.e06-net__zone--toppling { + fill: var(--e06-toppling); +} + +.e06-net__friction { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 5 3; +} + +.e06-net__friction--line { + stroke-dasharray: 2 3; +} + +.e06-net__envelope { + fill: none; + stroke: var(--e06-planar); + stroke-width: 1; + stroke-dasharray: 6 3; +} + +.e06-net__gc { + fill: none; + stroke: currentColor; + stroke-width: 1.2; +} + +.e06-net__slope { + fill: none; + stroke: var(--color-text); + stroke-width: 2.6; +} + +.e06-net__slope-pole { + fill: var(--color-text); +} + +.e06-net__pole circle { + fill: currentColor; + stroke: var(--color-surface); + stroke-width: 1; +} + +.e06-net__pole.is-risk circle, +.e06-net__line.is-risk path { + stroke: var(--e06-ng); + stroke-width: 2.4; +} + +.e06-net__pole text, +.e06-net__line text, +.e06-net__dir { + fill: var(--color-text); +} + +.e06-net__line path { + fill: var(--e06-wedge); + stroke: var(--color-surface); +} + +.e06-net__tick { + stroke: var(--color-text-secondary); +} + +.e06-tone-0 { + color: #2f6fd6; +} +.e06-tone-1 { + color: #1f9d6a; +} +.e06-tone-2 { + color: #c2410c; +} +.e06-tone-3 { + color: #7c4dcc; +} +.e06-tone-4 { + color: #b8860b; +} +.e06-tone-5 { + color: #0e7490; +} + +.e06-legend { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--color-text); +} + +.e06-legend__row { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.e06-legend__mark { + display: inline-block; + width: 16px; + height: 10px; + border-radius: 2px; +} + +.e06-legend__mark--slope { + height: 3px; + background: var(--color-text); +} +.e06-legend__mark--friction { + height: 0; + border-top: 1px dashed var(--color-text-secondary); +} +.e06-legend__mark--daylight { + height: 0; + border-top: 1px dashed var(--e06-planar); +} +.e06-legend__mark--planar { + background: color-mix(in srgb, var(--e06-planar) 35%, transparent); +} +.e06-legend__mark--wedge { + background: color-mix(in srgb, var(--e06-wedge) 35%, transparent); +} +.e06-legend__mark--toppling { + background: color-mix(in srgb, var(--e06-toppling) 35%, transparent); +} +.e06-legend__mark--line { + width: 10px; + background: var(--e06-wedge); + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} +.e06-legend__mark--pole { + width: 10px; + border-radius: 50%; + background: #2f6fd6; +} + +/* ---- 위험 띠 ---- */ +.e06-band { + display: flex; + flex-direction: column; + gap: 4px; +} + +.e06-band__legend { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--color-text); +} + +.e06-band__count { + margin-right: 6px; + color: var(--color-text-secondary); +} + +.e06-band__strip { + position: relative; + height: 18px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e06-band__seg { + position: absolute; + top: 0; + bottom: 0; + padding: 0; + border: none; + cursor: pointer; +} + +.e06-band__seg--ng { + background: var(--e06-ng); +} +.e06-band__seg--ok { + background: var(--e06-ok); +} +.e06-band__seg--check { + background: var(--e06-check); +} +.e06-band__seg--none { + background: var(--e06-none); +} + +.e06-band__seg.is-active { + outline: 2px solid var(--color-text); + outline-offset: -2px; + z-index: 1; +} + +.e06-band__ticks { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--color-text-secondary); +} + +/* ---- 우측 ---- */ +.e06-side { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.e06-list-slot { + max-height: 46vh; + overflow: auto; +} + +.e06-list, +.e06-cases { + width: 100%; + border-collapse: collapse; + font-size: 12px; + color: var(--color-text); +} + +.e06-list th, +.e06-cases th { + position: sticky; + top: 0; + padding: 3px 4px; + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-align: left; +} + +.e06-list td, +.e06-cases td { + padding: 3px 4px; + border-top: 1px solid var(--color-border); +} + +.e06-list tbody tr { + cursor: pointer; +} + +.e06-list tbody tr:hover { + background: var(--color-surface-raised); +} + +.e06-list tr.is-active { + background: color-mix(in srgb, var(--color-primary) 16%, transparent); +} + +.e06-detail h4 { + margin: 0 0 4px; + color: var(--color-text); +} + +.e06-verdict { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.e06-verdict::before { + content: ""; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--e06-none); +} + +.e06-verdict--ng::before { + background: var(--e06-ng); + border-radius: 2px; + transform: rotate(45deg); +} +.e06-verdict--ok::before { + background: var(--e06-ok); +} +.e06-verdict--check::before { + background: var(--e06-check); + border-radius: 0; + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} diff --git a/E06_StabRock/draft/E06_StabRock_Route.ts b/E06_StabRock/draft/E06_StabRock_Route.ts new file mode 100644 index 000000000..627bed897 --- /dev/null +++ b/E06_StabRock/draft/E06_StabRock_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E06_StabRock_Route.ts + * 길 선언(PLAN 536-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E06 암반 평사투영 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E06_STAB_ROCK, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E06_StabRock_UI_Page")).renderE06StabRock, +}; diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index d4d4e997d..b62289bfa 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -20,7 +20,13 @@ from config.config_db import get_db_pool KIND_STAGE_DIRS = { "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), - "STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"), + "STABILITY": ( + "E02_StabGround", + "E03_StabSection", + "E04_Stab3D", + "E05_StabReport", + "E06_StabRock", + ), } diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index 3c0c4c7d5..b6bb4f882 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -20,11 +20,11 @@ STAGE_KEYS = [ ] -# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05. +# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E06. STAGE_KEYS_BY_KIND = { "ROAD": STAGE_KEYS, "PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"], - "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"], + "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT", "STAB_ROCK"], } diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 9d295ec1c..2b373e02c 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -130,6 +130,7 @@ export const ROUTES = { E03_STAB_SECTION: "e03-stab-section", E04_STAB_3D: "e04-stab-3d", E05_STAB_REPORT: "e05-stab-report", + E06_STAB_ROCK: "e06-stab-rock", } as const; /** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */ @@ -139,12 +140,13 @@ export const PLAN_STEP_ROUTES = [ ROUTES.D04_PLAN_CONFIRM, ] as const; -/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */ +/** E 안정성평가 단계 주소(E02 → E06 · 생성 E01 은 단계 밖) */ export const STAB_STEP_ROUTES = [ ROUTES.E02_STAB_GROUND, ROUTES.E03_STAB_SECTION, ROUTES.E04_STAB_3D, ROUTES.E05_STAB_REPORT, + ROUTES.E06_STAB_ROCK, ] as const; export type RouteKey = keyof typeof ROUTES; diff --git a/config/config_system.py b/config/config_system.py index 2fa29bb33..26c19abdc 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -220,6 +220,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset( "E03_StabSection", "E04_Stab3D", "E05_StabReport", + "E06_StabRock", } ) diff --git a/main.py b/main.py index f9d6e072d..0e6c54572 100644 --- a/main.py +++ b/main.py @@ -84,6 +84,7 @@ from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_route from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router +from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -494,6 +495,7 @@ app.include_router(e02_stab_ground_router, dependencies=protected_with_company) app.include_router(e04_stab_3d_router, dependencies=protected_with_company) app.include_router(e05_stab_report_router, dependencies=protected_with_company) app.include_router(e03_stab_section_router, dependencies=protected_with_company) +app.include_router(e06_stab_rock_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_route_terrain_router, dependencies=protected_with_company) diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index e63553e50..6a912e3f4 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -257,7 +257,7 @@ def test_register_ignores_sent_company(): assert "company_name" not in data -@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)]) +@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 5)]) def test_stage_seed_by_kind(kind, count): log = [] asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind)) diff --git a/resources/tester/test_536_1_e06_rock.py b/resources/tester/test_536_1_e06_rock.py new file mode 100644 index 000000000..35fe8869b --- /dev/null +++ b/resources/tester/test_536_1_e06_rock.py @@ -0,0 +1,426 @@ +"""PLAN 536-1 — E06 암반 평사투영 · Markland · Hoek-Bray 평면 · 쐐기 (요구 345 · 351). + +기준 예제(문헌 값 · Rocscience 검증서가 옮긴 값): +- 평면 = Hoek(Sau Mau Ping, 『Practical Rock Engineering』 7장 · RocPlane 검증 #1) 균열 없음 0.8184254 · + 임계 균열 + 수위 0.9z 1.0654738 / Sharma 외(1995) 윗면 0~30° 무게(RocPlane 검증 #2) +- 쐐기 = Hoek & Bray 쐐기 계수(대칭 쐐기 마찰만 · 해석해) · Kovári & Fritz · Kumsar 외(2000) 실험 · + Priest(1993) 두 예제(SWedge 검증 #1~#7 값) +- Markland 경계 · 투영 왕복 · 위험 영역 = 판정 부등식 +""" + +import math + +import numpy as np +import pytest + +from E06_StabRock import E06_StabRock_Engine as eng +from E06_StabRock import E06_StabRock_Model as model +from E06_StabRock import E06_StabRock_Stereonet as net + +TOL = 0.02 # 완료 기준 ±2 % + + +def close(value, expect, tol=TOL): + assert value == pytest.approx(expect, rel=tol), (value, expect) + + +# ---- 평면 (Hoek-Bray) ------------------------------------------------------------- +SAU_MAU = dict( + H=60, slope_dip=50, plane_dip=35, c=100, phi=35, gamma=27, kh=0.08, gamma_w=10 +) # MN → kN · 0.1 MPa = 100 kPa + + +def test_plane_hoek_no_crack(): + r = eng.plane_fs(**SAU_MAU, water_fill=1.0) + close(r["F"], 0.8184254, 1e-4) + close(r["W"], 28627.8, 1e-4) + close(r["A"], 104.6068, 1e-5) + close(r["U"], 15691.0, 1e-4) + + +def test_plane_hoek_tension_crack(): + z = eng.plane_critical_depth_hoek(60, 50, 35) + close(z, 14.0092, 1e-5) + r = eng.plane_fs(**SAU_MAU, water_fill=0.9, crack={"mode": "depth", "z": z}) + close(r["F"], 1.0654738, 1e-4) + close(r["W"], 24843.9, 1e-4) + close(r["A"], 80.1826, 1e-5) + close(r["crack_b"], 15.3357, 1e-4) # 마루 뒤 거리(RocPlane 검증 #1) + + +def test_plane_critical_crack_search_matches_hoek(): + """건조 · 윗면 수평 — 수치 탐색 임계 자리 = Hoek-Bray 식 z · b.""" + r = eng.plane_fs( + H=60, slope_dip=50, plane_dip=35, c=20, phi=35, gamma=27, crack={"mode": "critical"} + ) + close(r["z"], eng.plane_critical_depth_hoek(60, 50, 35), 1e-4) + close(r["crack_b"], 15.3357, 1e-4) + + +@pytest.mark.parametrize( + "upper,weight,fs,fill", + [ + (0, 2392.38, 1.58612, 1.00), + (10, 2446.29, 1.58148, 0.84), + (20, 2503.66, 1.58382, 0.71), + (30, 2568.90, 1.57849, 0.61), + ], +) +def test_plane_upper_slope_sharma(upper, weight, fs, fill): + """윗면 기운 사면 · 연직 균열 마루 뒤 15.336 m · 균열 수위(RocPlane 정수 %).""" + kw = dict( + H=60, slope_dip=50, plane_dip=35, c=12, phi=45, gamma=2.6, gamma_w=1.0, upper_dip=upper + ) + r = eng.plane_fs(**kw, crack={"mode": "x", "b": 15.33576}, water_fill=fill) + close(r["W"], weight, 1e-4) + close(r["F"], fs) + + +def test_plane_bolt_vector(): + """수동 볼트 — 저항 += T cos(θ+ψp) + T sin(θ+ψp) tanφ (힘 벡터 풀이).""" + base = dict(H=10, slope_dip=60, plane_dip=35, c=10, phi=30, gamma=25) + r0 = eng.plane_fs(**base) + r1 = eng.plane_fs(**base, bolt_t=100, bolt_plunge=15) + a = math.radians(50) + expect = ( + r0["resist"] + 100 * math.cos(a) + 100 * math.sin(a) * math.tan(math.radians(30)) + ) / r0["drive"] + close(r1["F"], expect, 1e-9) + + +def test_plane_not_formed(): + assert eng.plane_fs(H=10, slope_dip=40, plane_dip=45, c=0, phi=30, gamma=25) is None + # 윗면이 활동면보다 가파르면 덩이 안 생김(균열 없음) + assert ( + eng.plane_fs(H=10, slope_dip=60, plane_dip=30, c=0, phi=20, gamma=25, upper_dip=35) is None + ) + + +# ---- 쐐기 (Hoek-Bray · SWedge 꼴) ------------------------------------------------ +FLAT = {"dip_direction": 180, "dip": 0} + + +def _wedge(j1, j2, slope, phi, H=10, upper=FLAT, **kw): + return eng.wedge_fs( + j1={"dip_direction": j1[0], "dip": j1[1], "c": 0, "phi": phi}, + j2={"dip_direction": j2[0], "dip": j2[1], "c": 0, "phi": phi}, + slope={"dip_direction": slope[0], "dip": slope[1]}, + upper=upper, + H=H, + gamma=26, + **kw, + ) + + +@pytest.mark.parametrize( + "j1,j2", [((141, 45), (219, 45)), ((119, 50), (241, 50)), ((150, 60), (230, 55))] +) +def test_wedge_hoek_bray_wedge_factor(j1, j2): + """마찰만 · 같은 φ — Hoek & Bray FS = K tanφ / tanψi · K = sinβ / sin(ξ/2) + (ξ = 두 면 사이 각(교선에 수직 단면) · β = 그 단면에서 쐐기 이등분선의 기울기).""" + phi = 35 + r = _wedge(j1, j2, (180, 70), phi) + n1 = eng.plane_normal(*j1) + n2 = eng.plane_normal(*j2) + i = eng.line_vector(*r["line"]) + # 교선에 수직인 면 안에서 두 면 안쪽 법선(위쪽) 사이 각 → 쐐기 사이 각 ξ = 180 − 그 각 + xi = math.pi - math.acos(float(n1 @ n2)) + bis = (n1 + n2) / np.linalg.norm(n1 + n2) # 쐐기 이등분선(교선에 수직 단면 안) + h = np.cross(i, [0.0, 0.0, 1.0]) + h /= np.linalg.norm(h) # 그 단면의 수평 + beta = math.acos(abs(float(bis @ h))) + K = math.sin(beta) / math.sin(xi / 2) + expect = K * math.tan(math.radians(phi)) / math.tan(math.radians(r["line"][1])) + close(r["F"], expect, 1e-3) + + +@pytest.mark.parametrize( + "j1,j2,slope,phi,plunge,fs", + [ + ((141, 45), (219, 45), (180, 70), 35, 37.85, 1.0), # Kovári & Fritz · SWedge #1 + ((119, 50), (241, 50), (180, 70), 35, 30.02, 1.6325), # SWedge #2 + ((195, 45), (105, 70), (160, 70), 30, None, 0.73), # Kumsar Case 3(Ankara) · SWedge 0.7123 + ((33.5, 75), (248, 75), (337.5, 75), 40.8, None, 2.02), # Kumsar Case 4(Dinar) + ], +) +def test_wedge_kumsar_kovari(j1, j2, slope, phi, plunge, fs): + r = _wedge(j1, j2, slope, phi) + assert r["mode"] == "12" + close(r["F"], fs, 0.03 if fs == 0.73 else 0.01) # 실험값 0.73 = 판독(SWedge 0.7123) + if plunge: + close(r["line"][1], plunge, 1e-3) + + +def test_wedge_kumsar_seismic(): + r = _wedge((33.5, 75), (248, 75), (337.5, 75), 40.8, kh=0.3303) + close(r["F"], 0.99) # Kumsar 0.99 · SWedge 0.9872 + + +def test_wedge_priest_double_plane(): + """Priest(1993) 예제 1 — 두 면 미끄럼 · 수압 5 · 15 kPa · 철탑 180 kN(168/70).""" + r = eng.wedge_fs( + j1={"dip_direction": 203, "dip": 47, "c": 10, "phi": 40}, + j2={"dip_direction": 287, "dip": 52, "c": 20, "phi": 35}, + slope={"dip_direction": 230, "dip": 60}, + upper={"dip_direction": 225, "dip": 5}, + H=6.7978, + gamma=26, + water={"pressure": [5, 15]}, + external=[{"force": 180, "trend": 168, "plunge": 70}], + ) + assert r["mode"] == "12" + close(r["F"], 1.4966, 1e-3) # Priest ≈ 1.5 + close(r["volume"], 45.201, 1e-3) + close(r["areas"][0], 41.147, 1e-3) + close(r["areas"][1], 20.428, 1e-3) + close(r["face_area"], 38.955, 1e-3) + close(r["N"][0], 407, 3e-3) + close(r["N"][1], 251, 3e-3) + + +def test_wedge_priest_single_plane(): + """Priest(1993) 예제 2 — 면 2 하나로 미끄럼(면 1 떨어짐) · 수압 25 · 15 kPa.""" + r = eng.wedge_fs( + j1={"dip_direction": 65, "dip": 74, "c": 15, "phi": 32}, + j2={"dip_direction": 186, "dip": 41, "c": 5, "phi": 40}, + slope={"dip_direction": 134, "dip": 65}, + upper={"dip_direction": 122, "dip": 11}, + H=6.8471, + gamma=25, + water={"pressure": [25, 15]}, + ) + assert r["mode"] == "2" + close(r["F"], 0.8493, 1e-3) # Priest ≈ 0.86 · SWedge 0.8493 + close(r["volume"], 81.741, 1e-3) + assert r["N"][0] == 0.0 + + +def test_wedge_gravity_water_and_bolt(): + """중력 수압 U = p³ γw Hw a / 6(SWedge · Hoek-Bray) · 볼트는 안전율을 낮추지 않음.""" + dry = _wedge((141, 45), (219, 45), (180, 70), 35) + wet = _wedge((141, 45), (219, 45), (180, 70), 35, water={"fill": 1.0}) + expect = eng.GAMMA_W * wet["Hw"] / 6 * wet["areas"][0] + close(wet["water_force"][0], expect, 1e-9) + assert wet["F"] < dry["F"] + bolted = _wedge( + (141, 45), (219, 45), (180, 70), 35, bolt={"force": 50, "trend": 0, "plunge": 15} + ) + assert bolted["F"] > dry["F"] + + +def test_wedge_not_formed(): + # 교선이 사면보다 가파름 → 사면으로 안 나옴 + assert _wedge((141, 75), (219, 75), (180, 50), 35) is None + + +# ---- Markland 경계 ------------------------------------------------------------- +SLOPE = {"dip_direction": 90.0, "dip": 70.0} + + +def _j(dd, dip, phi=30.0, jid="J"): + return {"id": jid, "dip_direction": dd, "dip": dip, "phi": phi} + + +@pytest.mark.parametrize( + "dd,dip,hit", + [ + (109.9, 45, True), + (110.1, 45, False), + (70.1, 45, True), + (90, 30.1, True), + (90, 29.9, False), + (90, 69.9, True), + (90, 70.1, False), + ], +) +def test_markland_planar(dd, dip, hit): + assert (eng.kinematic(SLOPE, [_j(dd, dip)])["planar"] == ["J"]) is hit + + +def test_markland_planar_apparent_daylight(): + """측방 15° — 겉보기 사면 경사(tanψf cos15°)를 넘는 절리는 안 나옴.""" + app = eng.apparent_dip(70, 90, 105) + assert eng.kinematic(SLOPE, [_j(105, app - 0.1)])["planar"] == ["J"] + assert eng.kinematic(SLOPE, [_j(105, app + 0.1)])["planar"] == [] + + +@pytest.mark.parametrize( + "dd,dip,hit", [(270, 50.1, True), (270, 49.9, False), (289.9, 80, True), (290.1, 80, False)] +) +def test_markland_toppling(dd, dip, hit): + """ψp > 90 − ψf + φ = 50 · 역방향 ±20.""" + assert (eng.kinematic(SLOPE, [_j(dd, dip)])["toppling"] == ["J"]) is hit + + +def test_markland_wedge_plunge_bounds(): + a, b = _j(50, 60, jid="A"), _j(130, 60, jid="B") + trend, plunge = eng.intersection(a, b) + assert trend == pytest.approx(90.0, abs=1e-6) + hit = eng.kinematic(SLOPE, [a, b])["wedge"] + assert hit and hit[0]["ids"] == ["A", "B"] + # φ 를 선주각 위로 올리면 사라짐 + hi = [dict(a, phi=plunge + 0.1), dict(b, phi=plunge + 0.1)] + assert eng.kinematic(SLOPE, hi)["wedge"] == [] + # 사면을 교선보다 완만하게 하면 안 나옴 + assert eng.kinematic({"dip_direction": 90, "dip": plunge - 0.1}, [a, b])["wedge"] == [] + + +# ---- 투영 ---------------------------------------------------------------------- +@pytest.mark.parametrize("kind", net.PROJECTIONS) +def test_projection_round_trip(kind): + rng = np.random.default_rng(536) + for trend, plunge in zip(rng.uniform(0, 360, 200), rng.uniform(0, 89.9, 200)): + x, y = net.project(trend, plunge, kind) + t2, p2 = net.inverse(x, y, kind) + assert p2 == pytest.approx(plunge, abs=2e-3) + # 좌표는 소수 다섯째 자리 — 중심 가까이는 추향 오차가 1e-5 / r rad 까지 + assert eng.ang_diff(t2, trend) <= math.degrees(1e-5 / math.hypot(x, y)) + 1e-6 + + +def test_projection_known_radii(): + assert net.radius(0, "equal_angle") == pytest.approx(1.0) + assert net.radius(0, "equal_area") == pytest.approx(1.0) + assert net.radius(90, "equal_angle") == pytest.approx(0.0) + assert net.radius(45, "equal_angle") == pytest.approx(math.tan(math.radians(22.5))) + assert net.radius(45, "equal_area") == pytest.approx( + math.sqrt(2) * math.sin(math.radians(22.5)) + ) + # 동쪽 수평선 = (1, 0) · 북쪽 = (0, 1) + assert net.project(90, 0, "equal_angle") == pytest.approx([1.0, 0.0], abs=1e-5) + assert net.project(0, 0, "equal_area") == pytest.approx([0.0, 1.0], abs=1e-5) + + +def _inside(pt, poly): + x, y = pt + hit = False + for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]): + if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1): + hit = not hit + return hit + + +@pytest.mark.parametrize("kind", net.PROJECTIONS) +def test_zones_match_kinematic(kind): + """위험 영역 다각형 안 극점 · 교선 = Markland 판정(경계 1° 띠 밖 표본).""" + rng = np.random.default_rng(7) + phi = 30.0 + data = net.stereonet(SLOPE, [], phi, kind) + checked = 0 + for dd, dip in zip(rng.uniform(0, 360, 1500), rng.uniform(1, 89, 1500)): + kin = eng.kinematic(SLOPE, [_j(dd, dip, phi)]) + near = ( + abs(eng.ang_diff(dd, 90) - 20) < 1 + or abs(eng.ang_diff(dd, 270) - 20) < 1 + or abs(dip - phi) < 1 + or abs(dip - eng.apparent_dip(70, 90, dd)) < 1 + or abs(dip - 50) < 1 + ) + if near: + continue + pole = net.project(*eng.pole(dd, dip), kind) + assert _inside(pole, data["zones"]["planar"]) == bool(kin["planar"]), (dd, dip) + assert _inside(pole, data["zones"]["toppling"]) == bool(kin["toppling"]), (dd, dip) + checked += 1 + assert checked > 1000 + + +# ---- 정본 · 측점 사면 ------------------------------------------------------------ +def _doc(): + doc = model.build_initial() + doc["sets"] = [ + { + "id": "J1", + "name": "J1", + "from_m": 0, + "to_m": 100, + "dip_direction": 90, + "dip": 45, + "phi": 30, + "c": 5, + "persistence_m": None, + } + ] + return doc + + +def test_check_and_clean(): + assert model.check_joints(_doc()) == [] + bad = _doc() + bad["sets"][0].update(dip=95, to_m=-1, name=" ") + bad["sets"].append(dict(bad["sets"][0])) + bad["settings"]["crack"] = {"mode": "depth", "z": None} + paths = {e["path"] for e in model.check_joints(bad)} + assert {"sets.0.dip", "sets.0.to_m", "sets.0.name", "sets.1.id", "settings.crack.z"} <= paths + clean = model.clean(dict(_doc(), extra=1)) + assert set(clean) == {"version", "sets", "settings"} + assert clean["sets"][0]["dip"] == 45.0 + + +def _cross(side_cut: str): + """좌(+offset) 또는 우 깎기 — 노선 북쪽 진행(left = 서쪽 −x) · 원지반 깎기 쪽으로 0.6 오름 · + 노면 99 · 깎기 1:0.4 · 쌓기 1:1.5.""" + sign = 1.0 if side_cut == "left" else -1.0 + offs = np.arange(-20, 20.5, 0.5) + ground = [[float(o), 100 + 0.6 * sign * o] for o in offs] + design = [] + for o, z in ground: + d = sign * o + if abs(o) <= 2: + z = 99.0 + elif d > 2: + z = min(z, 99 + 2.5 * (d - 2)) + else: + z = max(z, 99 - (abs(d) - 2) / 1.5) + design.append([o, z]) + return ( + { + "frame": {"left_xy": [-1.0, 0.0]}, + "samples": [{"offset_m": o, "elevation_m": z} for o, z in ground], + }, + { + "section_mode": f"{side_cut}_cut", + "cut_slope_ratio": 0.4, + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "design_line": [{"offset_m": o, "elevation_m": z} for o, z in design], + }, + ) + + +@pytest.mark.parametrize("side,dd", [("left", 90.0), ("right", 270.0)]) +def test_station_slope_orientation(side, dd): + """좌 = 서쪽 산 · 사면이 동(노면)으로 기욺 = 경사방향 90 · 우는 270 · 경사 = atan(1/0.4).""" + cross, design = _cross(side) + slopes = model.station_slopes(cross, design) + assert len(slopes) == 1 and slopes[0]["side"] == side + assert slopes[0]["dip_direction"] == pytest.approx(dd) + assert slopes[0]["dip"] == pytest.approx(math.degrees(math.atan(2.5)), abs=0.01) + assert slopes[0]["height_m"] > 1 + assert slopes[0]["upper_dip"] == pytest.approx(math.degrees(math.atan(0.6)), abs=0.5) + + +def test_analyze_station(monkeypatch): + cross, design = _cross("left") + monkeypatch.setattr( + model, + "rock_stations", + lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}], + ) + monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design}) + ground = { + "materials": [], + "stations": [], + "safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}}, + "seismic": {"enabled": False}, + } + out = model.analyze(None, _doc(), ground) + assert len(out) == 1 + e = out[0] + assert e["kinematic"]["planar"] == ["J1"] + assert {c["condition"] for c in e["cases"]} == {"dry", "wet"} + dry = next(c for c in e["cases"] if c["condition"] == "dry") + assert dry["required"] == 1.5 and dry["ok"] == (dry["F"] >= 1.5) + assert e["verdict"] in ("OK", "NG") + assert e["gamma_r"] == model.DEFAULT_GAMMA_R + nets = model.station_net(e, _doc()["settings"]) + assert set(nets) == {"equal_angle", "equal_area", "phi"} + assert nets["equal_area"]["zones"]["planar"] diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a5309b16a..7972847af 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -35,6 +35,7 @@ import { ui_locales_m2 } from "./ui_template_locale_m2"; import { ui_locales_de } from "./ui_template_locale_de"; import { ui_locales_e } from "./ui_template_locale_e"; import { ui_locales_e05 } from "./ui_template_locale_e05"; +import { ui_locales_e06 } from "./ui_template_locale_e06"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -77,6 +78,7 @@ export const ui_locales = { ...ui_locales_de, ...ui_locales_e, ...ui_locales_e05, + ...ui_locales_e06, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index a08abd22d..03c017e1e 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -17,6 +17,7 @@ export const ui_locales_de = { DE_Step_StabSection: ["2D 해석", "2D Analysis"], DE_Step_Stab3D: ["3D 해석", "3D Analysis"], DE_Step_StabReport: ["보고서", "Report"], + DE_Step_StabRock: ["암반 해석", "Rock Analysis"], DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."], /* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */ diff --git a/ui_template/ui_template_locale_e06.ts b/ui_template/ui_template_locale_e06.ts new file mode 100644 index 000000000..e604dca01 --- /dev/null +++ b/ui_template/ui_template_locale_e06.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * ui_template_locale_e06.ts + * 다국어 사전 — E06 암반 평사투영 해석(요구 345 · 351 · PLAN 536-1) + * + * 신규 문구는 해당 섹션 최하단에 추가. + * ========================================================================== */ + +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).", + ], + E06_Card_Joints: ["절리군", "Joint sets"], + E06_JointsHint: [ + "현장 조사값 — 경사방향 · 경사(°) · 절리면 φj(°) · cj(kPa) · 지속성(m · 비우면 무한). 구간(m) 안 측점에만 씁니다.", + "Field data — dip direction · dip (°) · joint φj (°) · cj (kPa) · persistence (m, blank = unlimited). Applies to stations within the range (m).", + ], + E06_Col_Name: ["이름", "Name"], + E06_Col_From: ["시작", "From"], + E06_Col_To: ["끝", "To"], + E06_Col_DipDir: ["경사방향", "Dip dir."], + E06_Col_Dip: ["경사", "Dip"], + E06_Col_Phi: ["φj", "φj"], + E06_Col_C: ["cj", "cj"], + E06_Col_Persist: ["지속성", "Persist."], + E06_Add: ["+ 절리군", "+ Joint set"], + E06_Remove: ["지움", "Remove"], + E06_NoSets: [ + "절리군이 없습니다 — [+ 절리군] 으로 넣으세요.", + "No joint sets — add one with [+ Joint set].", + ], + E06_Card_Settings: ["해석 설정", "Analysis settings"], + E06_Set_LateralPlanar: ["평면 측방 한계 (°)", "Planar lateral limit (°)"], + E06_Set_LateralTopple: ["전도 측방 한계 (°)", "Toppling lateral limit (°)"], + E06_Set_Gamma: ["암 단위중량 (kN/m³ · 비우면 E02)", "Rock unit weight (kN/m³, blank = E02)"], + E06_Set_WaterWet: ["우기 수위 비 (0~1)", "Wet water ratio (0–1)"], + E06_Set_Crack: ["인장균열", "Tension crack"], + E06_Crack_None: ["없음", "None"], + E06_Crack_Critical: ["임계(최소 안전율 자리)", "Critical (minimum FS)"], + E06_Crack_Depth: ["깊이 지정", "Given depth"], + E06_Set_CrackZ: ["균열 깊이 (m)", "Crack depth (m)"], + E06_Set_BoltForce: ["볼트 힘 (평면 kN/m · 쐐기 kN)", "Bolt force (plane kN/m · wedge kN)"], + E06_Set_BoltPlunge: ["볼트 경사 (수평 아래 °)", "Bolt plunge (below horizontal °)"], + E06_SettingsHint: [ + "측방 한계 기본 20° (Dips 기본 · 지식DB 03 전도는 30°) · 우기 = 비탈 높이 ½ 수위(KDS 11 70 05) · 볼트 = 수동 지지력.", + "Lateral limit default 20° (Dips default; knowledge DB uses 30° for toppling) · wet = water at half height (KDS 11 70 05) · bolts = passive support.", + ], + E06_Analyze: ["해석", "Analyze"], + 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_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], + E06_Invalid: ["틀린 칸", "Invalid fields"], + E06_Err_Range: ["범위 밖", "Out of range"], + E06_ResetConfirm: [ + "절리군 · 설정을 초기값(절리 없음)으로 되돌릴까요?", + "Reset joint sets and settings to the initial state (no joints)?", + ], + E06_ResetDone: ["초기값으로 되돌렸습니다.", "Reset to initial values."], + E06_NoProject: ["열린 프로젝트가 없습니다.", "No project is open."], + + /* --- E06 가운데 · 우측 --- */ + E06_Stale: [ + "절리 · 지반 조건이 해석 뒤 바뀌었습니다 — [해석] 을 다시 누르세요.", + "Joints or ground changed since the analysis — press [Analyze] again.", + ], + E06_Band: ["위험 띠", "Risk band"], + E06_View_All: ["전체", "All"], + E06_Mode_planar: ["평면", "Planar"], + E06_Mode_wedge: ["쐐기", "Wedge"], + E06_Mode_toppling: ["전도", "Toppling"], + E06_Proj_Angle: ["등각", "Equal angle"], + E06_Proj_Area: ["등면적", "Equal area"], + E06_PickStation: ["측점을 고르세요.", "Pick a station."], + E06_Lg_Slope: ["사면 대원 · 극점", "Slope plane · pole"], + E06_Lg_Friction: ["마찰원 φ", "Friction circle φ"], + E06_Lg_Planar: ["평면 위험(극점)", "Planar zone (poles)"], + E06_Lg_Daylight: ["일광 엔벨로프", "Daylight envelope"], + E06_Lg_Wedge: ["쐐기 위험(교선)", "Wedge zone (intersections)"], + E06_Lg_Line: ["교선", "Intersection"], + E06_Lg_Toppling: ["전도 위험(극점)", "Toppling zone (poles)"], + E06_Lg_Pole: ["절리 극점", "Joint pole"], + E06_Result_Title: ["측점 결과", "Station results"], + E06_NoResult: ["해석 결과가 없습니다 — [해석] 을 누르세요.", "No results — press [Analyze]."], + E06_NoRock: ["암 측점 깎기 비탈이 없습니다.", "No rock cut slopes."], + E06_L_Station: ["측점", "Station"], + E06_L_Side: ["쪽", "Side"], + E06_L_Modes: ["평 · 쐐 · 전", "P · W · T"], + E06_L_MinF: ["최소 Fs", "Min Fs"], + E06_L_Verdict: ["판정", "Verdict"], + E06_Left: ["좌", "L"], + E06_Right: ["우", "R"], + E06_V_NG: ["N.G", "N.G"], + E06_V_check: ["검토", "Check"], + E06_V_OK: ["O.K", "O.K"], + E06_V_none: ["위험 없음", "No risk"], + E06_SlopeLine: [ + "사면 {dd}° / {dip}° · 높이 {h} m · 윗면 {up}° · 암 γ {g} kN/m³", + "Slope {dd}° / {dip}° · height {h} m · upper {up}° · rock γ {g} kN/m³", + ], + E06_NoRisk: ["위험 모드 없음(Markland).", "No kinematic failure mode (Markland)."], + E06_NoJointsHere: ["이 측점 구간에 절리군이 없습니다.", "No joint sets cover this station."], + E06_C_Mode: ["모드", "Mode"], + E06_C_Joints: ["절리", "Joints"], + E06_C_Cond: ["조건", "Case"], + E06_C_F: ["Fs", "Fs"], + E06_C_Req: ["기준", "Req."], + E06_Cond_dry: ["건기", "Dry"], + E06_Cond_wet: ["우기", "Wet"], + E06_Cond_seismic: ["지진", "Seismic"], + E06_NotFormed: ["(블록 안 생김)", "(no block)"], + E06_Persist: ["(지속성 < 활동면)", "(persistence < plane)"], + E06_ToppleNote: [ + "전도 = 기하 판정만(블록 전도 안전율은 Goodman-Bray 정밀 검토 몫).", + "Toppling = kinematic only (block toppling FS needs a Goodman–Bray check).", + ], +} as const satisfies Record; From 24643c6507d729c2f5b1621cd0ebd12d5e10d7a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:27:33 +0900 Subject: [PATCH 15/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20356=20=E2=80=94=20=ED=95=9C=EB=8F=84=20=EA=B9=A8?= =?UTF-8?q?=EC=9B=80=20=EC=BC=9C=EA=B3=A0=20=EB=81=84=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A7=80=EC=8B=9C=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=88=20=EC=BC=AC=20=C2=B7=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index 5627df534..6c8de900c 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -151,3 +151,7 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 클로드 5시간 토큰 사용 제한에 걸릴것으로 예상됨. 토큰 풀린 후 바로 너를 깨워주는 스크립트가 있는지 확인. 1시간에 한번이면 됨 + +## 요구 356 (10-25 · 사용자) + +토크 한도 리밋 관련 스크립트 실행은 내가 on/off는 내가 지시하겠음. 현재는 o. 메모에서 삭제. From 29fb044d4476e566d9cc5b0a4025212878f3c739 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:31:57 +0900 Subject: [PATCH 16/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20357=20=E2=80=94=20=EC=9E=90=EB=8F=99=20=EA=B9=A8?= =?UTF-8?q?=EC=9B=80=EC=9D=84=20=EC=89=98(.claude/brain=5Fwake.sh)?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20Orca=20=ED=84=B0=EB=AF=B8=EB=84=90?= =?UTF-8?q?=EC=97=90=20=EB=B3=B4=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index 6c8de900c..b563fa978 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -155,3 +155,8 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 ## 요구 356 (10-25 · 사용자) 토크 한도 리밋 관련 스크립트 실행은 내가 on/off는 내가 지시하겠음. 현재는 o. 메모에서 삭제. + +## 요구 357 (10-25 · 사용자) + +on 이 맞음. 그런데 shell command가 아님? +쉘로 바꿔줘 뭐가 실행중인지 오르카 ide에 표시되는게 좋겠어 From 018761076703dbdab3ccfc505711433c113e9981 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:33:59 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat(536-2):=20E07=20=EA=B4=91=EC=97=AD?= =?UTF-8?q?=20=EC=9C=84=ED=97=98=20=ED=99=94=EB=A9=B4=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=ED=91=9C=20?= =?UTF-8?q?=C2=B7=20=EC=A1=B0=EA=B1=B4=20=C2=B7=20E=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=EB=A7=89=EB=8C=80=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E07_StabArea 화면: 좌 토층 물성(E02 물성 고름 · c · φ 범위) · 강우(지표 포화 · 강우 강도) · 띠 폭 · 격자 · [기본값] · [해석] · 진행률 - 가운데 지도: SINMAP 등급 · 안전율 Fs 색 · 노선 띠 · 측점 · 범례 · 셀 툴팁 · 우 요약 · 등급별 면적 · 측점별 위험 표(누름 = 지도 그 측점) - logic/E07_StabArea_Raster: 저장본 base64 격자 풀기 · 색 · RGBA - 공용 틀: STAB_STEP_ROUTES · group_workflow 단계 줄에 광역 위험 · 사전 ui_template_locale_e07 Co-Authored-By: Claude Opus 5.5 --- A00_Common/group_workflow.ts | 10 +- E07_StabArea/E07_StabArea_UI_Map.ts | 296 ++++++++++++++ E07_StabArea/E07_StabArea_UI_Page.ts | 478 ++++++++++++++++++++++ E07_StabArea/E07_StabArea_UI_Style.css | 231 +++++++++++ E07_StabArea/draft/E07_StabArea_Route.ts | 16 + E07_StabArea/logic/E07_StabArea_Raster.ts | 153 +++++++ config/config_frontend.ts | 4 +- ui_template/ui_template_locale.ts | 3 + ui_template/ui_template_locale_de.ts | 1 + ui_template/ui_template_locale_e07.ts | 83 ++++ 10 files changed, 1272 insertions(+), 3 deletions(-) create mode 100644 E07_StabArea/E07_StabArea_UI_Map.ts create mode 100644 E07_StabArea/E07_StabArea_UI_Page.ts create mode 100644 E07_StabArea/E07_StabArea_UI_Style.css create mode 100644 E07_StabArea/draft/E07_StabArea_Route.ts create mode 100644 E07_StabArea/logic/E07_StabArea_Raster.ts create mode 100644 ui_template/ui_template_locale_e07.ts diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 0fa1d7e21..209a42f25 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -31,8 +31,14 @@ const FLOWS: readonly GroupFlow[] = [ }, { routes: STAB_STEP_ROUTES, - labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], - icons: ["🪨", "📐", "🧊", "📄"], + labels: [ + "DE_Step_StabGround", + "DE_Step_StabSection", + "DE_Step_Stab3D", + "DE_Step_StabReport", + "DE_Step_StabArea", + ], + icons: ["🪨", "📐", "🧊", "📄", "🗺️"], }, ]; diff --git a/E07_StabArea/E07_StabArea_UI_Map.ts b/E07_StabArea/E07_StabArea_UI_Map.ts new file mode 100644 index 000000000..b2a77b581 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Map.ts @@ -0,0 +1,296 @@ +/* ============================================================================= + * E07_StabArea_UI_Map.ts + * E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 · + * 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster"; + +export type MapLayer = "cls" | "fs"; + +export interface StationPoint { + chainage_m: number; + label: string | null; + x: number; + y: number; +} + +export interface AreaMap { + root: HTMLElement; + setRoute: (line: number[][], stations: StationPoint[]) => void; + setBand: (halfWidthM: number) => void; + setResult: (data: Decoded | null) => void; + setLayer: (layer: MapLayer) => void; + focus: (chainage: number) => void; + dispose: () => void; +} + +interface View { + cx: number; + cy: number; + scale: number; // px / m +} + +const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); + +const css = (name: string, fallback: string): string => + getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + +export function createAreaMap(): AreaMap { + const root = el("div", { className: "e07-map" }); + const canvas = el("canvas", { className: "e07-map__canvas" }); + const tip = el("div", { className: "e07-map__tip" }); + const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") }); + tip.hidden = true; + root.append(canvas, tip, empty); + const ctx = canvas.getContext("2d")!; + + let line: number[][] = []; + let stations: StationPoint[] = []; + let band = 100; + let data: Decoded | null = null; + let layer: MapLayer = "cls"; + let image: HTMLCanvasElement | null = null; + let picked: number | null = null; + let view: View = { cx: 0, cy: 0, scale: 1 }; + let width = 0; + let height = 0; + + const toScreen = (x: number, y: number): [number, number] => [ + width / 2 + (x - view.cx) * view.scale, + height / 2 - (y - view.cy) * view.scale, + ]; + const toModel = (px: number, py: number): [number, number] => [ + view.cx + (px - width / 2) / view.scale, + view.cy - (py - height / 2) / view.scale, + ]; + + function buildImage(): void { + image = null; + if (!data) return; + const { nx, ny } = data.grid; + const off = document.createElement("canvas"); + off.width = nx; + off.height = ny; + off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0); + image = off; + } + + function fit(): void { + const xs = line.map((p) => p[0]); + const ys = line.map((p) => p[1]); + if (!xs.length || !width || !height) return; + const pad = band; + const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad]; + const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad]; + const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92; + view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale }; + } + + function path(): void { + ctx.beginPath(); + line.forEach((p, i) => + i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), + ); + } + + function drawRoute(text: string, accent: string): void { + if (line.length < 2) return; + ctx.lineJoin = ctx.lineCap = "round"; + // 노선 띠 — 중심선 ± band(옅은 띠) + ctx.globalAlpha = 0.12; + ctx.strokeStyle = accent; + ctx.lineWidth = Math.max(2 * band * view.scale, 1); + path(); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.strokeStyle = text; + ctx.lineWidth = 2; + path(); + ctx.stroke(); + const gap = stations.length > 1 ? 20 * view.scale : 999; + const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; + ctx.font = "11px sans-serif"; + ctx.textBaseline = "middle"; + stations.forEach((s, i) => { + const [px, py] = toScreen(s.x, s.y); + if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return; + const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6; + ctx.fillStyle = on ? accent : text; + ctx.beginPath(); + ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); + ctx.fill(); + if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9); + }); + } + + function drawFurniture(text: string): void { + ctx.fillStyle = text; + ctx.strokeStyle = text; + ctx.beginPath(); + ctx.moveTo(width - 24, 14); + ctx.lineTo(width - 30, 34); + ctx.lineTo(width - 18, 34); + ctx.closePath(); + ctx.fill(); + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("N", width - 24, 46); + const target = 100 / view.scale; + const pow = 10 ** Math.floor(Math.log10(target)); + const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target; + const px = meters * view.scale; + ctx.fillStyle = css("--color-surface", "#ffffff"); + ctx.globalAlpha = 0.85; + ctx.fillRect(width - 24 - px, height - 42, px + 16, 34); + ctx.globalAlpha = 1; + ctx.fillStyle = text; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(width - 16 - px, height - 16); + ctx.lineTo(width - 16, height - 16); + ctx.stroke(); + ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26); + ctx.textAlign = "start"; + } + + function draw(): void { + const dpr = window.devicePixelRatio || 1; + width = root.clientWidth; + height = root.clientHeight; + if (!width || !height) return; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const text = css("--color-text", "#1f2937"); + const accent = css("--color-primary", "#6d28d9"); + if (data && image) { + const g = data.grid; + const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale); + } + drawRoute(text, accent); + drawFurniture(text); + empty.hidden = Boolean(data); + } + + let drag: { x: number; y: number; view: View } | null = null; + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const k = Math.exp(-event.deltaY * 0.0015); + view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k }; + draw(); + }); + canvas.addEventListener("pointerdown", (event) => { + drag = { x: event.clientX, y: event.clientY, view: { ...view } }; + canvas.setPointerCapture(event.pointerId); + }); + canvas.addEventListener("pointerup", () => (drag = null)); + canvas.addEventListener("dblclick", () => { + fit(); + draw(); + }); + canvas.addEventListener("pointerleave", () => (tip.hidden = true)); + canvas.addEventListener("pointermove", (event) => { + if (drag) { + view = { + ...drag.view, + cx: drag.view.cx - (event.clientX - drag.x) / view.scale, + cy: drag.view.cy + (event.clientY - drag.y) / view.scale, + }; + draw(); + return; + } + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const i = data ? cellIndex(data.grid, mx, my) : -1; + if (i < 0 || !data!.cls[i]) return void (tip.hidden = true); + const near = stations.reduce( + (best, s) => + !best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2 + ? s + : best, + null, + ); + const m = data!.m[i]; + tip.textContent = [ + t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey), + `SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`, + `${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`, + near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", + ] + .filter(Boolean) + .join("\n"); + tip.style.left = `${event.clientX - rect.left + 14}px`; + tip.style.top = `${event.clientY - rect.top + 14}px`; + tip.hidden = false; + }); + + const resize = new ResizeObserver(() => { + const first = !width; + draw(); + if (first) { + fit(); + draw(); + } + }); + resize.observe(root); + const theme = new MutationObserver(draw); + theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + // 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 격자 · 층 + const probe = { + project: (x: number, y: number) => { + const rect = canvas.getBoundingClientRect(); + const [px, py] = toScreen(x, y); + return { x: rect.left + px, y: rect.top + py }; + }, + data: () => data, + layer: () => layer, + }; + (window as unknown as { __e07Map?: typeof probe }).__e07Map = probe; + + return { + root, + setRoute(nextLine, nextStations) { + line = nextLine; + stations = nextStations; + fit(); + draw(); + }, + setBand(halfWidthM) { + band = halfWidthM; + draw(); + }, + setResult(next) { + data = next; + buildImage(); + draw(); + }, + setLayer(next) { + layer = next; + buildImage(); + draw(); + }, + focus(chainage) { + picked = chainage; + const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6); + if (s) view = { ...view, cx: s.x, cy: s.y }; + draw(); + }, + dispose() { + resize.disconnect(); + theme.disconnect(); + if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe) + delete (window as unknown as { __e07Map?: unknown }).__e07Map; + }, + }; +} diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts new file mode 100644 index 000000000..f31216547 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -0,0 +1,478 @@ +/* ============================================================================= + * E07_StabArea_UI_Page.ts + * E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow). + * 좌 = 토층 물성(E02 물성 고름 · 범위) · 강우(지표 포화 · 강우 강도) · 노선 띠 · [해석] · 진행률 + * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 + * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) + * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. + * ========================================================================== */ + +import "./E07_StabArea_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, + type InputFieldHandle, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; +import { + CLASS_COLORS, + CLASS_KEYS, + FS_STEPS, + RISK_CLASS, + decode, + type AreaResult, +} from "./logic/E07_StabArea_Raster"; + +interface Material { + id: string; + name: string; + gamma_t: number; + gamma_sat: number; + c: number; + phi: number; +} + +type Settings = Record; + +interface Overview { + editable: boolean; + defaults: Settings; + materials: Material[]; + line: number[][]; + stations: StationPoint[]; + result: AreaResult | null; +} + +interface Progress { + running: boolean; + percent: number; + message: string | null; + error: string | null; +} + +const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ + ["gamma_t", "E07_GammaT", "soil"], + ["gamma_sat", "E07_GammaSat", "soil"], + ["c", "E07_C", "soil"], + ["c_hi", "E07_CHi", "soil"], + ["phi", "E07_Phi", "soil"], + ["phi_hi", "E07_PhiHi", "soil"], + ["c_root", "E07_CRoot", "soil"], + ["soil_depth_m", "E07_Depth", "soil"], + ["rain_mm_h", "E07_Rain", "rain"], + ["k_m_s", "E07_K", "rain"], + ["band_m", "E07_Band", "band"], + ["cell_m", "E07_Cell", "band"], +]; + +const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; +const shortLabel = (label: string | null, ch: number): string => + (label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, ""); + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + const text = Array.isArray(detail) + ? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" · ") + : detail; + throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`); + } + return body; +} + +function swatch(color: string): HTMLElement { + const chip = el("i", { className: "e07-chip" }); + chip.style.background = color; + return chip; +} + +function legend(layer: MapLayer): HTMLElement { + const box = el("div", { className: "e07-legend" }); + box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); + const rows = + layer === "cls" + ? CLASS_KEYS.map((k, i) => ({ + color: CLASS_COLORS[i], + label: t(`E07_Class_${k}` as LocaleKey), + })) + : FS_STEPS.map((s, i) => ({ + color: s.color, + label: + i === 0 + ? `< ${s.below.toFixed(1)}` + : s.below === Infinity + ? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}` + : `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`, + })); + for (const row of rows) { + const item = el("span", { className: "e07-legend__row" }); + item.append(swatch(row.color), el("span", { text: row.label })); + box.append(item); + } + return box; +} + +export function renderE07StabArea(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e07-panel" }); + const main = el("div", { className: "e07-page" }); + mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") })); + + // ---- 가운데 ---- + const map = createAreaMap(); + let layer: MapLayer = "cls"; + const legendSlot = el("div", { className: "e07-legend-slot" }); + const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); + const toggle = el("div", { className: "e07-toggle" }); + const layerButtons = ( + [ + ["E07_Layer_Class", "cls"], + ["E07_Layer_FS", "fs"], + ] as [LocaleKey, MapLayer][] + ).map(([key, value], i) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = value; + button.classList.toggle("is-on", i === 0); + button.addEventListener("click", () => { + layerButtons.forEach((b) => b.classList.toggle("is-on", b === button)); + layer = value; + map.setLayer(layer); + paintLegend(); + }); + return button; + }); + toggle.append(...layerButtons); + const stage = el("div", { className: "e07-stage" }); + stage.append(map.root, legendSlot); + const center = el("div", { className: "e07-center" }); + center.append(toggle, stage); + paintLegend(); + + // ---- 우 ---- + const resultBody = el("div", { className: "e07-result" }); + const side = createSidePanels({ + prefKey: "e07-stab-area-result", + panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e07-row" }); + row.append(center, side.root); + main.append(row); + + // ---- 좌 ---- + const fields = new Map(); + const cards = { + soil: createPanelCard(t("E07_Card_Soil"), false), + rain: createPanelCard(t("E07_Card_Rain"), false), + band: createPanelCard(t("E07_Card_Band"), false), + }; + const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); + const scenario = createSelectField({ + label: t("E07_Scenario"), + options: [ + { value: "saturated", text: t("E07_Scenario_saturated") }, + { value: "rain", text: t("E07_Scenario_rain") }, + ], + compact: true, + }); + cards.soil.append(material.root); + cards.rain.append(scenario.root); + for (const [key, label, card] of NUMBER_FIELDS) { + const field = createInputField({ label: t(label), type: "number", compact: true }); + field.input.step = "any"; + field.input.dataset.key = key; + fields.set(key, field); + cards[card].append(field.root); + } + const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } }); + const progressText = el("p", { className: "e07-progress__text" }); + const progressBox = el("div", { className: "e07-progress-box" }); + progressBox.append(progressBar, progressText); + progressBox.hidden = true; + const defaultsButton = createButton({ + label: t("E07_Defaults"), + variant: "ghost", + onClick: () => overview && write(overview.defaults), + }); + 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), + }); + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(defaultsButton, reanalyzeButton, analyzeButton); + const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") }); + readOnly.hidden = true; + panel.append( + el("p", { className: "e07-hint", text: t("E07_Hint") }), + readOnly, + cards.soil, + cards.rain, + cards.band, + progressBox, + actions, + ); + attachCollapsible(panel); + + // ---- 상태 ---- + let overview: Overview | null = null; + let running = false; + + function read(): Settings { + const out: Settings = { material_id: material.select.value || null }; + out.scenario = scenario.select.value; + for (const [key, field] of fields) out[key] = Number(field.input.value); + return out; + } + + function write(values: Settings): void { + for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); + material.select.value = String(values.material_id ?? ""); + scenario.select.value = String(values.scenario ?? "saturated"); + paintScenario(); + remember(); + } + + function paintScenario(): void { + const rain = scenario.select.value === "rain"; + for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain; + map.setBand(Number(fields.get("band_m")!.input.value) || 100); + } + + const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read())); + panel.addEventListener("input", remember); + scenario.select.addEventListener("change", () => { + paintScenario(); + 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)); + 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); + remember(); + }); + + function paintButtons(): void { + const editable = Boolean(overview?.editable); + analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + readOnly.hidden = !overview || editable; + } + + function paintResult(): void { + const result = overview?.result; + if (!result) { + resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") })); + return; + } + const s = result.summary; + const list = el("dl", { className: "e07-summary" }); + const pairs: [LocaleKey, string][] = [ + ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], + ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`], + ["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`], + [ + "E07_Sum_Covered", + `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`, + ], + [ + "E07_Sum_Time", + `${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`, + ], + ]; + for (const [key, value] of pairs) + list.append( + el("dt", { text: t(key) }), + el("dd", { text: value, attrs: { "data-field": key } }), + ); + const areas = el("table", { className: "e07-table e07-areas" }); + const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1; + areas.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Class") }), + el("th", { text: t("E07_Col_Area") }), + el("th", { text: "%" }), + ], + }), + ...CLASS_KEYS.map((k, i) => { + const name = el("td"); + name.append( + swatch(CLASS_COLORS[i]), + el("span", { text: t(`E07_Class_${k}` as LocaleKey) }), + ); + return el("tr", { + attrs: { "data-class": k }, + children: [ + name, + el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }), + el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }), + ], + }); + }), + ); + const table = el("table", { className: "e07-table e07-stations" }); + table.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Station") }), + el("th", { text: t("E07_Col_FsMin") }), + el("th", { text: t("E07_Col_SiMin") }), + el("th", { text: t("E07_Col_Worst") }), + el("th", { text: t("E07_Col_RiskArea") }), + ], + }), + ...result.stations.map((r) => { + const worst = el("td"); + if (r.worst_class) + worst.append( + swatch(CLASS_COLORS[r.worst_class - 1]), + el("span", { text: t(`E07_Class_${CLASS_KEYS[r.worst_class - 1]}` as LocaleKey) }), + ); + const tr = el("tr", { + className: (r.worst_class ?? 0) >= RISK_CLASS ? "is-risk" : "", + attrs: { "data-chainage": String(r.chainage_m) }, + children: [ + el("td", { text: shortLabel(r.label, r.chainage_m) }), + el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }), + el("td", { text: r.si_min === undefined ? "-" : r.si_min.toFixed(2) }), + worst, + el("td", { + text: + r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), + }), + ], + }); + tr.addEventListener("click", () => { + table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on")); + tr.classList.add("is-on"); + map.focus(r.chainage_m); + }); + return tr; + }), + ); + const notes: HTMLElement[] = []; + if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") })); + notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") })); + resultBody.replaceChildren( + list, + el("h4", { text: t("E07_Areas_Title") }), + areas, + el("h4", { text: t("E07_Stations_Title") }), + table, + ...notes, + ); + } + + async function refresh(): Promise { + overview = await api(`/projects/${projectId}/stab/area`); + material.setOptions( + overview.materials.map((m) => ({ value: m.id, text: m.name })), + String(overview.defaults.material_id ?? ""), + ); + map.setResult(overview.result ? decode(overview.result) : null); + paintButtons(); + paintResult(); + } + + async function poll(): Promise { + while (root.isConnected) { + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (!p) break; + running = p.running; + progressBox.hidden = !p.running; + progressBar.value = p.percent; + progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`; + paintButtons(); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else showToast(t("E07_Done"), "success"); + await refresh(); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + async function analyze(force: boolean): Promise { + try { + const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { + method: "POST", + body: JSON.stringify({ ...read(), force }), + }); + if (body.status === "cached") { + showToast(t("E07_Cached"), "info"); + await refresh(); + return; + } + running = true; + paintButtons(); + progressBox.hidden = false; + void poll(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + + // ---- 처음 ---- + paintButtons(); + void (async () => { + try { + await refresh(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e07-empty", + text: error instanceof Error ? error.message : String(error), + }), + ); + return; + } + const ov = overview!; + const cached = JSON.parse( + sessionStorage.getItem(cacheKey(projectId)) ?? "null", + ) as Settings | null; + write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults); + map.setRoute(ov.line, ov.stations); + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (p?.running) void poll(); + })(); + + const leave = new MutationObserver(() => { + if (root.isConnected && main.isConnected) return; + leave.disconnect(); + map.dispose(); + }); + leave.observe(document.body, { childList: true, subtree: true }); +} diff --git a/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css new file mode 100644 index 000000000..ee82afbc2 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -0,0 +1,231 @@ +/* ============================================================================= + * E07_StabArea_UI_Style.css + * E07 광역 위험 지도 — 좌측 조건 · 진행 / 가운데 지도 · 범례 / 우측 요약 · 측점 표 + * 색은 테마 토큰만(밝음 · 어두움 같이) · 등급 · 안전율 색만 logic 고정색 + * ========================================================================== */ + +.e07-page { + height: 100dvh; + box-sizing: border-box; +} + +.e07-row { + height: 100%; +} + +.e07-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; +} + +.e07-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e07-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e07-stage { + position: relative; + flex: 1 1 auto; + min-height: 320px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e07-map { + position: absolute; + inset: 0; +} + +.e07-map__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; + cursor: grab; +} + +.e07-map__tip { + position: absolute; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; + white-space: pre; + pointer-events: none; +} + +.e07-map__empty { + position: absolute; + inset: auto 0 45% 0; + margin: 0; + text-align: center; + color: var(--color-text-muted); + pointer-events: none; +} + +.e07-legend-slot { + position: absolute; + left: var(--spacing-8); + bottom: var(--spacing-8); + pointer-events: none; +} + +.e07-legend { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; +} + +.e07-legend__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.e07-chip { + display: inline-block; + width: 14px; + height: 10px; + flex: none; + margin-right: var(--spacing-4); + border-radius: 2px; + border: 1px solid var(--color-border); + vertical-align: middle; +} + +/* 좌측 */ +.e07-hint, +.e07-note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; +} + +.e07-panel .ui-field[hidden] { + display: none; +} + +.e07-progress-box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e07-progress-box[hidden] { + display: none; +} + +.e07-progress { + width: 100%; +} + +.e07-progress__text { + margin: 0; + color: var(--color-text); + font-size: 12px; +} + +/* 우측 */ +.e07-result { + padding: var(--spacing-12); + color: var(--color-text); +} + +.e07-result h4 { + margin: var(--spacing-16) 0 var(--spacing-8); + font-size: 13px; +} + +.e07-summary { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-8) var(--spacing-12); + margin: 0; + font-size: 13px; +} + +.e07-summary dt { + color: var(--color-text-muted); +} + +.e07-summary dd { + margin: 0; + font-weight: 600; + text-align: right; +} + +.e07-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.e07-table th, +.e07-table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: right; + white-space: nowrap; +} + +.e07-table th:first-child, +.e07-table td:first-child, +.e07-stations td:nth-child(4) { + text-align: left; +} + +.e07-table th { + color: var(--color-text-muted); + font-weight: 600; +} + +.e07-stations tr[data-chainage] { + cursor: pointer; +} + +.e07-stations tr[data-chainage]:hover { + background: var(--color-surface-raised); +} + +.e07-stations tr.is-risk td { + color: var(--color-danger); + font-weight: 600; +} + +.e07-stations tr.is-on { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.e07-warn { + margin: var(--spacing-12) 0 0; + color: var(--color-danger); + font-size: 12px; +} + +.e07-empty { + margin: var(--spacing-16); + color: var(--color-text-muted); +} diff --git a/E07_StabArea/draft/E07_StabArea_Route.ts b/E07_StabArea/draft/E07_StabArea_Route.ts new file mode 100644 index 000000000..08b2db164 --- /dev/null +++ b/E07_StabArea/draft/E07_StabArea_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E07_StabArea_Route.ts + * 길 선언(PLAN 536-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E07 광역 위험 지도 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E07_STAB_AREA, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea, +}; diff --git a/E07_StabArea/logic/E07_StabArea_Raster.ts b/E07_StabArea/logic/E07_StabArea_Raster.ts new file mode 100644 index 000000000..a9a00b8e7 --- /dev/null +++ b/E07_StabArea/logic/E07_StabArea_Raster.ts @@ -0,0 +1,153 @@ +/* ============================================================================= + * E07_StabArea_Raster.ts + * E07 광역 위험 지도 화면 계산(PLAN 536-2) — 저장본 격자 풀기 · 등급 · 안전율 색 · RGBA · 셀 찾기. + * 격자 꼴 = 서버 `E07_StabArea_Router.py` 머리 주석(fs · si uint16 ×1000 · cls uint8 · slope ×10 · + * m ×100 · 작은 끝 base64 · 행 0 = 남쪽). 순수 함수만(DOM · fetch 금지). + * ========================================================================== */ + +export interface Grid { + x0: number; + y0: number; + dx: number; + nx: number; + ny: number; +} + +export interface StationRow { + chainage_m: number; + label: string | null; + cells: number; + fs_min?: number; + si_min?: number; + worst_class?: number; + risk_area_m2?: number; + slope_max_deg?: number; +} + +export interface AreaSummary { + areas_m2: Record; + band_area_m2: number; + covered_area_m2: number; + risk_area_m2: number; + fs_min: number | null; + risk_stations: number[]; + stations: number; +} + +export interface AreaResult { + stale?: boolean; + computed_at: string; + elapsed_s: number; + settings: Record; + grid: Grid; + fs: string; + si: string; + cls: string; + slope: string; + m: string; + stations: StationRow[]; + summary: AreaSummary; +} + +/** 풀어 둔 격자 — 자료 없음 = NaN(fs · si · slope · m) · 0(cls) */ +export interface Decoded { + grid: Grid; + fs: Float32Array; + si: Float32Array; + cls: Uint8Array; + slope: Float32Array; + m: Float32Array; +} + +/** 등급 열쇠(서버 CLASS_KEYS 차례 · 1~6) */ +export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const; +export const RISK_CLASS = 4; + +/** 등급 색 1~6 — SINMAP 관례(초록 → 빨강) */ +export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"]; + +/** 안전율 색 단계 — 1.0 · 1.2 · 1.5 경계 */ +export const FS_STEPS: readonly { below: number; color: string }[] = [ + { below: 1.0, color: "#a50026" }, + { below: 1.2, color: "#f46d43" }, + { below: 1.5, color: "#fee08b" }, + { below: 2.0, color: "#a6d96a" }, + { below: Infinity, color: "#1a9850" }, +]; + +function bytes(text: string): Uint8Array { + const raw = atob(text); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i); + return out; +} + +function u16(text: string, scale: number): Float32Array { + const b = bytes(text); + const view = new DataView(b.buffer); + const out = new Float32Array(b.length / 2); + for (let i = 0; i < out.length; i += 1) { + const v = view.getUint16(i * 2, true); + out[i] = v === 65535 ? NaN : v / scale; + } + return out; +} + +export function decode(result: AreaResult): Decoded { + const m = bytes(result.m); + return { + grid: result.grid, + fs: u16(result.fs, 1000), + si: u16(result.si, 1000), + cls: bytes(result.cls), + slope: u16(result.slope, 10), + m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)), + }; +} + +function rgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function fsColor(value: number): string { + return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color; +} + +/** 격자 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 자료 없음 = 투명 */ +export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray { + const { nx, ny } = data.grid; + const out = new Uint8ClampedArray(nx * ny * 4); + const classRgb = CLASS_COLORS.map(rgb); + const fsRgb = FS_STEPS.map((s) => rgb(s.color)); + for (let iy = 0; iy < ny; iy += 1) { + const row = ny - 1 - iy; + for (let ix = 0; ix < nx; ix += 1) { + const i = iy * nx + ix; + const k = data.cls[i]; + if (!k) continue; + const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below); + const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j]; + const at = (row * nx + ix) * 4; + out[at] = color[0]; + out[at + 1] = color[1]; + out[at + 2] = color[2]; + out[at + 3] = 255; + } + } + return out; +} + +/** 모델 좌표 → 셀 번호(격자 밖 = -1) */ +export function cellIndex(grid: Grid, x: number, y: number): number { + const ix = Math.round((x - grid.x0) / grid.dx); + const iy = Math.round((y - grid.y0) / grid.dx); + return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? -1 : iy * grid.nx + ix; +} + +/** 등급별 셀 수(1~6) — 범례 · 검증용 */ +export function classCounts(data: Decoded): number[] { + const out = [0, 0, 0, 0, 0, 0]; + for (const k of data.cls) if (k) out[k - 1] += 1; + return out; +} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 9d295ec1c..f51c90f91 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -130,6 +130,7 @@ export const ROUTES = { E03_STAB_SECTION: "e03-stab-section", E04_STAB_3D: "e04-stab-3d", E05_STAB_REPORT: "e05-stab-report", + E07_STAB_AREA: "e07-stab-area", } as const; /** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */ @@ -139,12 +140,13 @@ export const PLAN_STEP_ROUTES = [ ROUTES.D04_PLAN_CONFIRM, ] as const; -/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */ +/** E 안정성평가 단계 주소(E02 → E05 · 광역 E07 · 생성 E01 은 단계 밖) */ export const STAB_STEP_ROUTES = [ ROUTES.E02_STAB_GROUND, ROUTES.E03_STAB_SECTION, ROUTES.E04_STAB_3D, ROUTES.E05_STAB_REPORT, + ROUTES.E07_STAB_AREA, ] as const; export type RouteKey = keyof typeof ROUTES; diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a5309b16a..2d80685d3 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -16,6 +16,7 @@ * - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11) * - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기 * - ui_template_locale_e04.ts : E04 안정성평가 3D 해석 + * - ui_template_locale_e07.ts : E07 안정성평가 광역 위험 지도 * * 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다. * ========================================================================== */ @@ -35,6 +36,7 @@ import { ui_locales_m2 } from "./ui_template_locale_m2"; import { ui_locales_de } from "./ui_template_locale_de"; import { ui_locales_e } from "./ui_template_locale_e"; import { ui_locales_e05 } from "./ui_template_locale_e05"; +import { ui_locales_e07 } from "./ui_template_locale_e07"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -77,6 +79,7 @@ export const ui_locales = { ...ui_locales_de, ...ui_locales_e, ...ui_locales_e05, + ...ui_locales_e07, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index a08abd22d..c5a587294 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -17,6 +17,7 @@ export const ui_locales_de = { DE_Step_StabSection: ["2D 해석", "2D Analysis"], DE_Step_Stab3D: ["3D 해석", "3D Analysis"], DE_Step_StabReport: ["보고서", "Report"], + DE_Step_StabArea: ["광역 위험", "Area Hazard"], DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."], /* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */ diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts new file mode 100644 index 000000000..036187a13 --- /dev/null +++ b/ui_template/ui_template_locale_e07.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * ui_template_locale_e07.ts + * 다국어 사전 — E07 안정성평가 광역 자연사면 위험 지도(요구 345 · 351 · PLAN 536-2) + * + * 신규 문구는 해당 섹션 최하단에 추가. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_e07 = { + /* --- E07 광역 위험 지도 --- */ + E07_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + E07_Hint: [ + "노선 띠 안 자연사면의 표층 붕괴 위험 — 무한사면 안전율과 SINMAP 안정 지수(SI)로 등급을 매깁니다. 지형 = 복사한 원지형.", + "Shallow-landslide hazard of natural slopes in the route band — infinite-slope Fs and SINMAP stability index (SI). Terrain = copied original DTM.", + ], + E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], + E07_Card_Soil: ["토층 물성", "Soil layer"], + E07_Card_Rain: ["강우 · 포화", "Rain · saturation"], + E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"], + E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"], + E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"], + E07_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"], + E07_C: ["c 아래 (kPa)", "c low (kPa)"], + E07_CHi: ["c 위 (kPa)", "c high (kPa)"], + E07_Phi: ["φ 아래 (°)", "φ low (°)"], + E07_PhiHi: ["φ 위 (°)", "φ high (°)"], + E07_CRoot: ["뿌리 점착 (kPa)", "Root cohesion (kPa)"], + E07_Depth: ["토층 두께 (m)", "Soil depth (m)"], + E07_Scenario: ["시나리오", "Scenario"], + E07_Scenario_saturated: ["지표 포화 (m = 1)", "Fully saturated (m = 1)"], + E07_Scenario_rain: ["강우 강도 (정상류)", "Rainfall (steady state)"], + E07_Rain: ["강우 강도 (mm/h)", "Rainfall (mm/h)"], + E07_K: ["투수계수 (m/s)", "Hydraulic conductivity (m/s)"], + E07_Band: ["띠 폭 ± (m)", "Band half-width (m)"], + E07_Cell: ["격자 (m)", "Cell (m)"], + 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."], + E07_Layer_Class: ["등급 (SI)", "Class (SI)"], + E07_Layer_FS: ["안전율 Fs", "Safety factor Fs"], + E07_Legend_Class: ["SINMAP 등급", "SINMAP class"], + E07_Legend_FS: ["무한사면 Fs", "Infinite-slope Fs"], + E07_Class_stable: ["안정 (SI ≥ 1.5)", "Stable (SI ≥ 1.5)"], + E07_Class_moderate: ["보통 안정 (≥ 1.25)", "Moderately stable (≥ 1.25)"], + E07_Class_quasi: ["준안정 (≥ 1.0)", "Quasi-stable (≥ 1.0)"], + E07_Class_lower: ["위험 하한 (≥ 0.5)", "Lower threshold (≥ 0.5)"], + E07_Class_upper: ["위험 상한 (> 0)", "Upper threshold (> 0)"], + E07_Class_defended: ["불안정 (SI = 0)", "Defended (SI = 0)"], + E07_Map_Empty: [ + "[해석] 을 누르면 위험 지도가 나옵니다.", + "Press [Analyze] to draw the hazard map.", + ], + E07_Tip_Slope: ["경사", "Slope"], + E07_Tip_Station: ["가까운 측점", "Nearest station"], + E07_Result_Title: ["광역 결과", "Area result"], + E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."], + E07_Result_Stale: [ + "엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].", + "The engine changed — re-analyze.", + ], + E07_Result_Note: [ + "문헌 추정치 · 현장 시험 없음. SI = c · φ 범위 안에서 Fs > 1 일 확률(SI ≥ 1 이면 가장 나쁜 값의 Fs). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.", + "Literature values, no field tests. SI = probability of Fs > 1 over the c · φ ranges (worst-case Fs when SI ≥ 1). Upslope area beyond the terrain grid is not counted.", + ], + E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"], + E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"], + E07_Sum_RiskStations: ["위험 측점", "Hazard stations"], + E07_Sum_Covered: ["지형 있는 띠 / 띠", "Band with terrain / band"], + E07_Sum_Time: ["해석", "Computed"], + E07_Areas_Title: ["등급별 면적", "Area by class"], + E07_Stations_Title: ["측점별 위험 (누르면 지도로)", "Hazard by station (click to locate)"], + E07_Col_Class: ["등급", "Class"], + E07_Col_Area: ["면적 (m²)", "Area (m²)"], + E07_Col_Station: ["측점", "Station"], + E07_Col_FsMin: ["최소 Fs", "Min Fs"], + E07_Col_SiMin: ["최소 SI", "Min SI"], + E07_Col_Worst: ["나쁜 등급", "Worst class"], + E07_Col_RiskArea: ["위험 m²", "Hazard m²"], +} as const satisfies Record; From 948dc4f0c9da9e222e085e4fb1c8c8d1e41870fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 21:35:10 +0900 Subject: [PATCH 18/34] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20357=20=EB=8D=A7=20=E2=80=94=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B9=A8=EC=9B=80=20=3D=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=EC=B0=BD=20=EB=B0=B1=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EB=AA=85=EB=A0=B9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1025/사용자요구_1025.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1025/사용자요구_1025.md b/docs/raw/요구_1025/사용자요구_1025.md index b563fa978..63c5bca75 100644 --- a/docs/raw/요구_1025/사용자요구_1025.md +++ b/docs/raw/요구_1025/사용자요구_1025.md @@ -160,3 +160,5 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은 on 이 맞음. 그런데 shell command가 아님? 쉘로 바꿔줘 뭐가 실행중인지 오르카 ide에 표시되는게 좋겠어 + +그말이 아니라 지금 너와의 대화창에는 감시 스크립트가 돌면 wait for worker reports 라고 shell command 항목이 보여 이걸로 실행여부를 판단하고 싶다고 From 0eb9b9071a3d0d42ad008b0e8806eb42974f069f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 22:46:57 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat(532-5):=20E03=20=E2=86=92=20E05=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EA=BC=B4=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=83=84=20=C2=B7=20=EC=A7=80=ED=95=98=EC=88=98=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EC=A7=80=EB=AC=B8=20=C2=B7=20r=5Fu=20=EB=B2=94?= =?UTF-8?q?=EB=A1=80=20=C2=B7=20=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [해석] 끝 · [대표 단면 저장] 때 E03_StabSection/_derived/result2d.json(측점 줄 · 지배 비탈 · 조건별 최소 · 판정) · results/<대표 측점>.json · representative.json stations 덧붙임(overrides 그대로) - 측점 지문에 E02 지하수 갈래 · 일꾼 job 에 지반 조건(sub4 analyze_station 고리 뒤 측점 수위로 이음) - 측점 보기 water_rule — r_u 갈래면 범례에 r_u 값(수위선 없음) - 시험 test_532_5_e03_seam — 벽 측점 임계면 · 벽 측점만 지문 · 지하수 갈래 지문 · 수위선 갈래 · 빠진 횡단 · E05 가 E03 저장본으로 빌더 돎 Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_Router.py | 52 ++++- E03_StabSection/E03_StabSection_UI_Page.ts | 9 + E03_StabSection/E03_StabSection_View.py | 38 +++ E03_StabSection/E03_StabSection_Worker.py | 12 +- .../logic/E03_StabSection_Logic.ts | 2 + resources/tester/test_532_5_e03_seam.py | 216 ++++++++++++++++++ ui_template/ui_template_locale_e03.ts | 4 + 7 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 resources/tester/test_532_5_e03_seam.py diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 82ef4bea5..7828b9e71 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -64,6 +64,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] KIND_STATION = worker.KIND_STATION KIND_SUMMARY = "stab2d_summary" REPRESENTATIVE = Path("E03_StabSection") / "representative.json" +#: E05 보고서가 읽는 꼴(`E05_StabReport_Build` · result2d.json · results/<측점>.json) +REPORT_DIR = Path("E03_StabSection") / "_derived" ENGINE_FILES = ( "E03_StabSection_Types.py", "E03_StabSection_Engine_Model.py", @@ -132,6 +134,7 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: "conditions": conditions, "seismic_kh": float(seismic.get("kh") or 0.0) if seismic.get("enabled") else 0.0, "road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0), + "groundwater": ground.get("groundwater"), } resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} try: @@ -158,7 +161,14 @@ def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: ) factors = ground.get("safety_factors") or {} overall = derived_key([item["fp"] for item in items], factors) - return {"source": source, "shared": shared, "items": items, "factors": factors, "fp": overall} + return { + "source": source, + "ground": ground, + "shared": shared, + "items": items, + "factors": factors, + "fp": overall, + } def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str, Any]: @@ -235,6 +245,7 @@ def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: "root": str(root), "keep": max(3 * len(items), 6), "workers": WORKERS or max(1, (os.cpu_count() or 2) - 2), + "conditions_ground": plan["ground"], "kwargs": { "materials": shared["materials"], "conditions": list(shared["conditions"]), @@ -269,9 +280,38 @@ def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: "stations": rows, } write_derived(root, KIND_SUMMARY, plan["fp"], summary) + export_report(root, summary) return len(todo) +def export_report(root: Path, summary: dict[str, Any]) -> None: + """E05 보고서 입력 — 요약(측점 줄) · 대표 단면 측점 해석 결과 · representative.json `stations` + (E03 은 `overrides` 만 읽음). [해석] 끝 · [대표 단면 저장] 때 씀.""" + rows = [dict(r) for r in summary["stations"]] + overrides = _overrides(root) + view.mark_representative(rows, overrides) + folder = root / REPORT_DIR + atomic_write_json( + folder / "result2d.json", + { + "input_fingerprint": summary["fingerprint"], + "computed_at": summary["computed_at"], + "conditions": summary["conditions"], + "stations": [view.report_row(r) for r in rows], + }, + ) + reps = view.report_representatives(rows) + results = folder / "results" + for old in results.glob("*.json"): + old.unlink() + by_key = {r["key"]: r for r in rows} + for rep in reps: + saved = read_derived(root, KIND_STATION, by_key[rep["station_key"]]["fp"]) + if saved and saved["value"]["out"]: + atomic_write_json(results / f"{rep['station_key']}.json", saved["value"]["out"]) + atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides, "stations": reps}) + + def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]: """(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과).""" saved = read_derived(root, KIND_SUMMARY, fp) @@ -398,13 +438,15 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]: "walls": walls_mod.walls_of(entry), } dry = section_model_from_b06(cross, design, **kw) - water = {} + water, rules = {}, {} for cond in summary["conditions"]: # 수위선 = E02 고른 갈래(r_u · 없음 = 선 없음) model = section_model_from_b06(cross, design, wet=cond == "wet", **kw) gw = ground_model.station_groundwater(ground, cond, model) water[cond] = gw.get("line") if gw.get("mode") == "line" else None + rules[cond] = {"mode": gw.get("mode"), "ru": gw.get("ru")} out = view.section_view(row, value["out"], dry, water) + out["water_rule"] = rules out["fresh"] = fresh return out @@ -413,12 +455,16 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]: async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]: """대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만.""" root, _ = await e02._project(project_id) - known = {item["key"] for item in plan_run(root, _ground(root))["items"]} + plan = plan_run(root, _ground(root)) + known = {item["key"] for item in plan["items"]} unknown = sorted(set(payload.overrides) - known) if unknown: raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}") overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None} atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides}) + summary, _ = _latest_summary(root, plan["fp"]) + if summary: + export_report(root, summary) return {"overrides": overrides} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index bd1c3c1b1..c84fc4af9 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -296,6 +296,15 @@ export function renderE03StabSection(root: HTMLElement): void { const draw = { view, analysis, toggles, picked }; canvas.render(draw); legendHost.append(buildLegends(draw)); + const rule = view.water_rule?.[condition]; + if (rule?.mode === "ru") + legendHost.append( + el("p", { + className: "e03-muted", + text: t("E03_Water_Ru").replace("{v}", String(rule.ru)), + attrs: { "data-role": "water-ru" }, + }), + ); if (view.regions.some((r) => r.material_id === WALL_ID)) legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); resultHost.append( diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py index fcefcfbcb..df766049d 100644 --- a/E03_StabSection/E03_StabSection_View.py +++ b/E03_StabSection/E03_StabSection_View.py @@ -130,6 +130,44 @@ def mark_representative(rows: list[dict[str, Any]], overrides: dict[str, bool]) } +#: 대표 단면 까닭 → E05 보고서 열쇠(`E05_StabReport_Build.REASONS`) +REPORT_REASONS = {"fill_height": "fill_10m", "cut_height": "cut_15m", "lowest": "lowest_fs"} + + +def report_row(row: dict[str, Any]) -> dict[str, Any]: + """E05 보고서가 읽는 측점 줄 — 지배 비탈(전 조건 최소 안전율 쪽) 역할 · 조건별 최소 · 판정.""" + worst = None + for by_side in row["cases"].values(): + for side, value in by_side.items(): + if value["fs"] is not None and (worst is None or value["fs"] < worst[0]): + worst = (value["fs"], side) + roles = {s["side"]: s["role"] for s in row["sides"]} + values = [v for v in row["min"].values() if v is not None] + return { + "station_key": row["key"], + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "side": roles.get(worst[1]) if worst else None, + "fs_min": min(values) if values else None, + "fs_by_condition": dict(row["min"]), + "method": PRIMARY, + "verdict": overall_verdict(row), + "walls": row.get("walls") or 0, + } + + +def report_representatives(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """E05 대표 단면 목록(켠 것만) — `mark_representative` 뒤 행.""" + out = [] + for row in rows: + rep = row.get("representative") or {} + if rep.get("on"): + auto = rep.get("auto") or [] + reason = "user" if rep.get("user") or not auto else REPORT_REASONS.get(auto[0], auto[0]) + out.append({"station_key": row["key"], "reason": reason, "enabled": True}) + return out + + def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, Any]: """머리 요약 — 조건별 최소 안전율 · N.G 측점 수.""" out: dict[str, Any] = {"stations": len(rows), "errors": sum(1 for r in rows if r["error"])} diff --git a/E03_StabSection/E03_StabSection_Worker.py b/E03_StabSection/E03_StabSection_Worker.py index 2e661d97b..72d43a14b 100644 --- a/E03_StabSection/E03_StabSection_Worker.py +++ b/E03_StabSection/E03_StabSection_Worker.py @@ -3,7 +3,8 @@ `python -m E03_StabSection.E03_StabSection_Worker ` — 서버(`Router.run_all`)가 띄움. 측점마다 프로세스로 나눠 돌리고 끝날 때마다 파생 저장본을 쓰고 한 줄(JSON)을 찍음 → 서버 진행률. 서버 프로세스에서 바로 프로세스 풀을 열면 자식마다 `main.py` 를 다시 읽어 이 모듈을 입구로 둠. -job = {root, keep, kwargs, workers, items: [{fp, meta, layers, cross, design, walls, ground}]}. +job = {root, keep, kwargs, workers, conditions_ground, items: [{fp, meta, layers, cross, design, +walls, ground}]} · conditions_ground = E02 지반 조건(지하수 갈래 → 측점 수위). """ from __future__ import annotations @@ -22,7 +23,9 @@ KIND_STATION = "stab2d_station" logger = logging.getLogger(__name__) -def _analyze(item: dict[str, Any], kwargs: dict[str, Any]) -> dict[str, Any]: +def _analyze( + item: dict[str, Any], kwargs: dict[str, Any], conditions_ground: dict | None = None +) -> dict[str, Any]: kw = {**kwargs, "layers": item["layers"], "ground": [tuple(p) for p in item["ground"]]} if item.get("walls"): kw["walls"] = item["walls"] @@ -41,6 +44,7 @@ def _value(item: dict[str, Any], out: dict | None, error: str | None) -> dict[st def run_job(job: dict[str, Any], emit: Callable[[str], None]) -> None: """측점마다 해석 → 저장본 · emit(열쇠). 한 측점 실패는 그 줄에 까닭만 남기고 이어 감.""" root, keep, kwargs = Path(job["root"]), int(job["keep"]), job["kwargs"] + conditions_ground = job.get("conditions_ground") items = job["items"] def save(item: dict[str, Any], out: dict | None, error: str | None) -> None: @@ -51,13 +55,13 @@ def run_job(job: dict[str, Any], emit: Callable[[str], None]) -> None: if workers == 1: for item in items: try: - save(item, _analyze(item, kwargs), None) + save(item, _analyze(item, kwargs, conditions_ground), None) except Exception as error: # noqa: BLE001 logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"]) save(item, None, str(error)) return with ProcessPoolExecutor(max_workers=workers) as pool: - futures = {pool.submit(_analyze, item, kwargs): item for item in items} + futures = {pool.submit(_analyze, item, kwargs, conditions_ground): item for item in items} for future in as_completed(futures): item = futures[future] try: diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index f1bcda7ce..bbab80681 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -106,6 +106,8 @@ export interface SectionView { bottom_z: number | null; surcharge: { x0: number; x1: number; q: number }[]; water: Partial>; + /** 조건별 지하수 꼴(E02 갈래) — r_u 면 수위선 없음 */ + water_rule?: Partial>; analyses: Analysis[]; } diff --git a/resources/tester/test_532_5_e03_seam.py b/resources/tester/test_532_5_e03_seam.py new file mode 100644 index 000000000..9c2bcaa08 --- /dev/null +++ b/resources/tester/test_532_5_e03_seam.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +"""532-5 E03 이음매 — 2D 해석 길에 벽(532-4) · 지하수 갈래(E02) · 빠진 횡단 알림(532-1 덧). + +- 벽: 검증_sub2 복사본 벽 선 측점(351 m 기슭막이) — 길이 벽을 넘김 · 임계면이 벽을 안 지남 · + 측점 보기에 벽 폴리곤 · 목록 벽 수 · 벽 있는 측점만 지문이 바뀜 +- 지하수: E02 우기 갈래를 바꾸면 측점 지문이 바뀜 · 측점 보기 수위선 = 고른 갈래 +- 빠진 횡단: 횡단 파일이 없으면 result2d · stab/ground 의 missing_stations 에 그 측점 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_532_5_e03_seam.py -q +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import sys +import tempfile +from pathlib import Path +from uuid import UUID + +import numpy as np +import pytest +from shapely.geometry import LineString, Polygon + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from common_util import common_util_project_copy as cp # noqa: E402 +from common_util.common_util_station_key import chainage_key # noqa: E402 +from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402 +from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402 +from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402 +from E03_StabSection import E03_StabSection_Router as router # noqa: E402 +from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402 +from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402 + +SOURCE_ID = "cb15892a-5788-4a26-9316-20a15421f68b" # 검증_sub2(읽기만) +PID = "e" * 8 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 4 + "-" + "e" * 12 +WALL_KEY = "351.00" # 검증_sub2 기슭막이 측점 +CORNER_M = 0.2 # 임계면이 벽 모서리를 스치는 길이 한도(test_532_4 와 같음) +USER = {"role": "USER", "company_id": 1} + + +async def _copy(target: Path) -> None: + import aiomysql + + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + connection = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True + ) + try: + await cp.copy_project( + connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target + ) + finally: + connection.close() + + +@pytest.fixture(scope="module") +def frozen(): + folder = Path(tempfile.mkdtemp(prefix="aislo_e03_seam_")) + try: + asyncio.run(_copy(folder / "copy")) + except Exception as error: # noqa: BLE001 + shutil.rmtree(folder, ignore_errors=True) + pytest.skip(f"검증 원본 복사 실패: {error}") + root = folder / "copy" + if WALL_KEY not in router._walls(str(src.source_dir(root)), "seam"): + shutil.rmtree(folder, ignore_errors=True) + pytest.skip("검증_sub2 에 벽 측점이 없음") + yield root + shutil.rmtree(folder, ignore_errors=True) + + +def _fast(cross, design, **kw): + """진짜 엔진 — Bishop 원호만(빠르게).""" + return analyze_station(cross, design, **{**kw, "methods": ("bishop",), "noncircular": False}) + + +@pytest.fixture +def project(frozen, monkeypatch): + """벽 측점 + 벽 없는 측점 하나 · 진짜 엔진(빠르게) · 서버 안에서 차례로.""" + for name in ("_derived", "E02_StabGround", "E03_StabSection", "initial_snapshot"): + shutil.rmtree(frozen / name, ignore_errors=True) + original = src.stations + + def pick(root): + rows = original(root) + wall = [r for r in rows if chainage_key(r["chainage_m"]) == WALL_KEY] + return wall + [r for r in rows if chainage_key(r["chainage_m"]) != WALL_KEY][:1] + + monkeypatch.setattr(router.src, "stations", pick) + + async def fake_project(_pid): + return frozen, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(e02, "_project", fake_project) + monkeypatch.setattr(worker, "analyze_station", _fast) + monkeypatch.setattr(router, "WORKERS", 1) + router._JOBS.clear() + return frozen + + +def _analyze() -> dict: + async def go(): + first = await router.analyze2d(UUID(PID)) + while router._TASKS: + await asyncio.gather(*list(router._TASKS)) + return first + + return asyncio.run(go()) + + +def _save_ground(root: Path, ground: dict) -> None: + ground_model.ground_path(root).parent.mkdir(parents=True, exist_ok=True) + ground_model.ground_path(root).write_text(json.dumps(ground), encoding="utf-8") + + +def test_벽_측점_임계면이_벽을_안_지남(project): + assert _analyze()["status"] == "accepted" + assert router._JOBS[PID]["error"] is None + body = asyncio.run(router.result2d(UUID(PID), USER)) + walls = {r["key"]: r["walls"] for r in body["stations"]} + assert walls[WALL_KEY] >= 1 and sorted(walls.values())[0] == 0 + sec = asyncio.run(router.section(UUID(PID), WALL_KEY)) + polys = [Polygon(r["polygon"]) for r in sec["regions"] if r["material_id"] == "wall"] + assert polys and any(m["id"] == "wall" for m in sec["materials"]) + crossed = 0 + for a in sec["analyses"]: + line = LineString(a["critical"]["points"]) + for poly in polys: + assert line.intersection(poly).length <= CORNER_M + crossed += 1 + assert crossed == len(sec["analyses"]) > 0 + + +def test_벽_있는_측점만_지문이_바뀜(project, monkeypatch): + ground = router._ground(project) + with_walls = {it["key"]: it["fp"] for it in router.plan_run(project, ground)["items"]} + monkeypatch.setattr(router, "_walls", lambda source, fp: {}) + plain = {it["key"]: it["fp"] for it in router.plan_run(project, ground)["items"]} + assert with_walls[WALL_KEY] != plain[WALL_KEY] + assert [with_walls[k] == plain[k] for k in plain if k != WALL_KEY] == [True] + + +def test_지하수_갈래_바꾸면_지문_바뀜(project): + ground = router._ground(project) + before = router.plan_run(project, ground) + ground["groundwater"]["wet_mode"] = "ru" + after = router.plan_run(project, ground) + assert before["fp"] != after["fp"] + assert all(a["fp"] != b["fp"] for a, b in zip(before["items"], after["items"])) + + +def test_수위선_고른_갈래(project): + _analyze() + key = next(r["key"] for r in asyncio.run(router.result2d(UUID(PID), USER))["stations"]) + first = asyncio.run(router.section(UUID(PID), key)) + kds, surface = first["water"]["wet"], first["surface"] + ground = router._ground(project) + ground["groundwater"].update(wet_mode="ru", ru=0.3) + _save_ground(project, ground) + assert asyncio.run(router.section(UUID(PID), key))["water"]["wet"] is None # r_u = 선 없음 + ground["groundwater"].update(wet_mode="depth_ratio", depth_ratio=0.5) + _save_ground(project, ground) + deep = asyncio.run(router.section(UUID(PID), key))["water"]["wet"] + assert kds and deep + # 깊이 비율 선 = 공사 뒤 지표 아래 · 비탈 쪽은 확실히 아래(연직 면 x 는 뺌 — 지표 값이 둘) + sx, sz = zip(*surface) + steps = {x for x in sx if sx.count(x) > 1} + below = [float(np.interp(x, sx, sz)) - z for x, z in deep if x not in steps] + assert min(below) >= -1e-6 and max(below) > 0.1 + + +def test_빠진_횡단_목록(project): + source = src.source_dir(project) + row = src._cross_rows(source)[3] + path = src.resolve_cross_file(source, row) + moved = path.with_suffix(".bak") + path.rename(moved) + try: + body = asyncio.run(router.result2d(UUID(PID), USER)) + ground = asyncio.run(e02.get_ground(UUID(PID), USER)) + finally: + moved.rename(path) + assert body["missing_stations"] == [float(row["chainage_m"])] + assert ground["missing_stations"] == [float(row["chainage_m"])] + assert asyncio.run(router.result2d(UUID(PID), USER))["missing_stations"] == [] + + +def test_E05_가_E03_저장본으로_보고서(project): + """E03 [해석] 이 쓴 E05 꼴(result2d.json · results/ · representative stations)로 E05 가 돎.""" + from E05_StabReport import E05_StabReport_Build as build + + _analyze() + inputs = build.gather(project) + rows = inputs["result2d"]["stations"] + assert len(rows) == 2 and WALL_KEY in {r["station_key"] for r in rows} + for r in rows: + assert r["side"] in ("cut", "fill") and r["verdict"] in ("ok", "ng") + assert r["fs_min"] == min(v for v in r["fs_by_condition"].values() if v is not None) + reps = inputs["representatives"] + assert reps and all(rep["result"] and rep["result"]["cases"] for rep in reps) + book = build.form() + jobs = build.build_program(book, inputs) + assert len([k for k, svg in jobs if svg.startswith("= len(reps) + + # 대표 단면 끔 → E05 목록에서 빠짐 · E03 overrides 는 그대로 읽힘 + key = reps[0]["station_key"] + asyncio.run( + router.put_representative(UUID(PID), router.RepresentativeRequest(overrides={key: False})) + ) + assert key not in [r["station_key"] for r in build.gather(project)["representatives"]] + assert router._overrides(project) == {key: False} diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 0093ac164..f51cdf387 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -74,6 +74,10 @@ export const ui_locales_e03 = { E03_Legend_Layers: ["지층", "Layers"], E03_Offset: ["거리", "Offset"], E03_Walls: ["벽 있음", "Walls"], + E03_Water_Ru: [ + "지하수 = 간극수압비 r_u {v}(수위선 없음)", + "Groundwater = pore pressure ratio r_u {v} (no water table)", + ], E03_Walls_Hint: [ "옹벽 · 석축(회색 빗금)은 강한 물체로 넣어 활동면이 지나지 않습니다.", "Retaining walls (gray hatch) are modeled as rigid bodies — slip surfaces do not cross them.", From 770d5b27c79818c963244e8b89da78599969f418 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 22:58:23 +0900 Subject: [PATCH 20/34] =?UTF-8?q?fix(534-4):=20E05=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=A7=80=EC=B8=B5=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=E2=80=94=20=EC=A7=80=EB=B0=98=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=EC=97=90=20=EC=95=88=20=EC=9D=B4=EC=9D=80=20=EC=A7=80?= =?UTF-8?q?=EC=B8=B5=EC=9D=80=20=E3=80=8C-=E3=80=8D(=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=EC=A7=80=EC=B8=B5=EC=9D=B4=20=E3=80=8C?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=E3=80=8D=EB=A1=9C=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83)=20=C2=B7=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=EC=9C=84=20=EC=A7=80=EC=B8=B5=20=EC=A4=84=EC=97=90=20=ED=86=A0?= =?UTF-8?q?=EC=B8=B5=20=EB=91=90=EA=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Build.py | 11 ++++++++--- resources/tester/test_534_3_e05_report.py | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index becbb50b0..e8c771ad7 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -27,9 +27,9 @@ from datetime import date from pathlib import Path from typing import Any +from common_util.common_util_derived_store import read_derived from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model -from common_util.common_util_derived_store import read_derived from E03_StabSection import E03_StabSection_View as view2d from E03_StabSection.E03_StabSection_Engine_Model import final_surface from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides @@ -339,14 +339,19 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: _set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0)) defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]} names = {m["id"]: m["name"] for m in ground.get("materials") or []} - rows: list[tuple[dict[str, Any], str]] = [(m, "전체") for m in ground.get("materials") or []] + mapped = set((ground.get("label_map") or {}).values()) # 지반 라벨에 이은 지층만 「전체」 + rows: list[tuple[dict[str, Any], str]] = [ + (m, "전체" if m["id"] in mapped else "-") for m in ground.get("materials") or [] + ] for zone in ground.get("zones") or []: span = f"{float(zone['from_m']):g} ~ {float(zone['to_m']):g} m" for layer, where in (("soil", "위"), ("rock", "아래")): mid = zone.get(layer) match = next((m for m in ground["materials"] if m["id"] == mid), None) if match: - rows.append((match, f"{span} {where} 지층")) + depth = zone.get("soil_thickness_m") if layer == "soil" else None + more = f" · 두께 {float(depth):g} m" if depth is not None else "" + rows.append((match, f"{span} {where} 지층{more}")) if zone.get("soil_thickness_m") is not None and not (zone.get("soil") or zone.get("rock")): rows.append(({"name": "토층 두께"}, f"{span} · {zone['soil_thickness_m']:g} m")) for i, (m, span) in enumerate(rows): diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 63c43062c..559d192b0 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1,8 +1,8 @@ """534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. -가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · 3D 저장본 하나 · -복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · -덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. +가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · +3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · +갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. """ import io From 52858c5078e39cef4e5807c26bc663a3b1a5cf7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:00:57 +0900 Subject: [PATCH 21/34] =?UTF-8?q?fix(534-4):=20E05=203D=EC=9A=94=EC=95=BD?= =?UTF-8?q?=20=E2=80=94=202D=20=EC=B5=9C=EC=86=8C=20=3D=203D=20=EC=99=80?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EC=A1=B0=EA=B1=B4(=EC=9A=B0=EA=B8=B0?= =?UTF-8?q?=203D=20=EB=A5=BC=20=EA=B1=B4=EA=B8=B0=20=C2=B7=20=EC=A0=84=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B5=9C=EC=86=8C=EC=99=80=20=EA=B2=AC?= =?UTF-8?q?=EC=A3=BC=EB=8D=98=20=EA=B2=83)=20=C2=B7=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=3D=20E04=20=EC=97=94=EC=A7=84=20=ED=8C=90=EC=A0=95(?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=95=88=EC=A0=84=EC=9C=A8=20=EA=B1=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=A0=EC=A0=95=EC=9D=B4=EB=8D=98=20=EA=B2=83)?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=ED=97=98=203D=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=B3=B8=EC=97=90=20condition=20=C2=B7=20verdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Build.py | 13 +++++++++---- resources/tester/test_534_3_e05_report.py | 14 +++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index e8c771ad7..1972d4844 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -136,6 +136,7 @@ def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]: cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides }, "fs_min": min(values) if values else None, + "min_by_condition": row.get("min") or {}, "method": method, "verdict": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, "representative": row.get("representative") or {}, @@ -553,19 +554,23 @@ def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]: ground = inputs["ground"] labels = _labels(inputs["stations"]) rows2d = inputs["result2d"]["stations"] - crit = _criterion(ground, "dry") sections = [] for i, sec in enumerate(inputs["sections3d"]): r = 20 + i _copy_row(sheet, 20, r, "ABCDEGH") start, end = float(sec["start_m"]), float(sec["end_m"]) + cond = sec.get("condition") or "dry" # 2D 는 3D 와 같은 조건끼리 견줌 inside = [ - float(s["fs_min"]) + float(s["min_by_condition"][cond]) for s in rows2d - if s.get("fs_min") is not None and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6 + if s["min_by_condition"].get(cond) is not None + and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6 ] f3 = float(sec["f_min"]) - ok = f3 >= crit - 1e-9 + if sec.get("verdict") in ("ok", "ng"): # 판정 = E04 엔진 값(기준안전율도 E04) + ok = sec["verdict"] == "ok" + else: + ok = f3 >= _criterion(ground, cond) - 1e-9 _set(sheet, f"A{r}", f"{i + 1}구간" + (" (미리보기)" if sec.get("preview") else "")) _set(sheet, f"B{r}", _label_at(labels, start)) _set(sheet, f"C{r}", _label_at(labels, end)) diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 559d192b0..597c484d3 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -157,7 +157,15 @@ def project(tmp_path, monkeypatch) -> Path: { "fingerprint": "3d-1", "settings": {}, - "summary": {"start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0}, + "summary": { + "start_m": 0.0, + "end_m": 200.0, + "f_min": 1.42, + "volume": 830.0, + "condition": "wet", + "reference_fs": 1.3, + "verdict": "ok", + }, } ), encoding="utf-8", @@ -215,8 +223,8 @@ def test_처음_만듦_측점수_NG_그림(project): assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층") assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3 s6 = by["3D요약"]["칸"] - assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "N.G", "서식": build.ST_NG} - assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 최소 + assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정 + assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건) assert (build.initial_dir(project) / build.REPORT).is_file() assert saved["fingerprint"] == build.analysis_fingerprint(project) From 0a6066f19bba57ad7a1c175ad355675c2f788429 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:03:48 +0900 Subject: [PATCH 22/34] =?UTF-8?q?fix(534-4):=20528=20E01=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20warnings=20=EC=B9=B8(532-1=20=EB=8D=A7=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC=20=EA=B2=BD=EA=B3=A0)=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=EC=97=90=20=EB=84=A3=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 낡은 기대값: 532-1 덧에서 E01 생성 응답에 warnings 를 더했는데 528 시험은 옛 응답 꼴을 봄. Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_528_project_groups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index e63553e50..be54ebcd6 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -165,7 +165,7 @@ def test_e01_requires_b06(monkeypatch): call() assert e.value.status_code == 404 seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}]) - assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40} + assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40, "warnings": []} assert seen["copied"] == ("stab-id", "s" * 36) assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36 assert seen["name"] == "임도 안정성평가" From 7de68f0ca1aa4da710667ef2db9b24f75ea6fb98 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:14:54 +0900 Subject: [PATCH 23/34] =?UTF-8?q?fix(532-5):=20E03=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EA=B8=80(=EC=95=88=EC=A0=84=EC=9C=A8=20=C2=B7=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC)=EC=9D=B4=20=EC=B2=98=EC=9D=8C=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=ED=99=95=EB=8C=80=20=EB=B0=B0=EC=9C=A8=EB=A7=8C=ED=81=BC=20?= =?UTF-8?q?=EC=BB=A4=EC=A7=80=EB=8D=98=20=EA=B2=83=20=E2=80=94=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EB=8A=94=20=EB=B0=B0=EC=9C=A8=20=EC=97=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_UI_Section.ts | 16 +++++++++++++--- E03_StabSection/E03_StabSection_UI_Style.css | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index 7a50b1f15..8bbd73618 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -305,7 +305,14 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec labels.append( svgEl( "text", - { x: ax + 8, y: ay - 10, class: "e03-label e03-label--fs", "data-role": "fs-label" }, + { + x: ax, + y: ay, + dx: "0.6em", + dy: "-0.7em", + class: "e03-label e03-label--fs", + "data-role": "fs-label", + }, `Fs = ${fmt(critical.fs)}`, ), ); @@ -313,7 +320,7 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec labels.append( svgEl( "text", - { x: ax + 8, y: ay + 6, class: "e03-label" }, + { x: ax, y: ay, dx: "0.7em", dy: "0.5em", class: "e03-label" }, `Xc ${fmt(critical.center[0], 2)} · Zc ${fmt(critical.center[1], 2)} · R ${fmt(critical.radius, 2)} m`, ), ); @@ -351,7 +358,10 @@ export function createSectionCanvas(onPick: (index: number | null) => void): Sec maxScale: MAX_SCALE, home: focusHome(fit, view, analysis, width, height, MAX_SCALE), initial: zoom, - onChange: (state) => (zoom = state), + onChange: (state) => { + zoom = state; + labels.style.setProperty("--e03-label-k", String(1 / state.scale)); // 글자는 확대 안 함 + }, }); }; diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index 63c515748..c7bbb4419 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -420,14 +420,14 @@ .e03-label { fill: var(--color-text); - font-size: 12px; + font-size: calc(12px * var(--e03-label-k, 1)); paint-order: stroke; stroke: var(--e03-plot); - stroke-width: 3px; + stroke-width: calc(3px * var(--e03-label-k, 1)); } .e03-label--fs { - font-size: 14px; + font-size: calc(14px * var(--e03-label-k, 1)); font-weight: 700; } From cdd9fa3c0b6b891c44498eedadc483fcf9d88d4c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:17:20 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat(536-2):=20E07=20=EA=B4=91=EC=97=AD?= =?UTF-8?q?=20=EC=9C=84=ED=97=98=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=9E=AC?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=E2=80=94=20=EA=B8=B0=EB=B3=B8=20=3D=20SIN?= =?UTF-8?q?MAP=20=ED=91=9C=EC=A4=80=20=EC=8A=B5=EC=9C=A4=EC=A7=80=EC=88=98?= =?UTF-8?q?(T/R=202,000~3,000=20m=20=C2=B7=20SI=20=ED=95=98=ED=95=9C/?= =?UTF-8?q?=EC=83=81=ED=95=9C/=ED=99=95=EB=A5=A0)=20=C2=B7=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=20=ED=8F=AC=ED=99=94=20=3D=20=EC=B5=9C=EC=95=85=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=C2=B7=20=EB=BF=8C=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=90=EC=B0=A9=201=20kPa=20=C2=B7=20E05=20=EA=B4=91?= =?UTF-8?q?=EC=97=AD=EC=9A=94=EC=95=BD=20=C2=B7=20=EB=B2=94=EB=A1=80=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=9D=B4=EB=A6=84(?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진: 포화비 W = min(x·a/sinβ, 1) · x = R/T 범위 · SI = 하한(c · φ 아래 · 가장 젖음) ≥ 1 → 그 값 · 상한 < 1 → 0 · 사이 = P(Fs > 1)(c · tanφ 닫힌 꼴 · x 중점 적분 20 마디) - 옛 「강우 강도(정상류)」 시나리오 뺌 — 첨두 강우를 정상류 함양으로 넣어 거의 다 포화 · T/R 로 갈음 · 엔진 판 e07-2(옛 결과 = 다시 해석) - 기본값 출처 = SINMAP 2 설명서 기본 보정 · 국내 적용 연구(뿌리 0~5 kPa) · 지식DB 예정노선/02 · 바꿀 수 있음 - 시험 536-2: 강우 포화비 시험을 습윤지수 · SI 하한/상한 Pack 원식 · 시나리오 둘로 바꿈 · 534-3 광역요약 B7 글 맞춤 - 검증 노선: 위험(SI<1) 91.7 % → 11.1 % Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Area.py | 19 ++-- E07_StabArea/E07_StabArea_Engine.py | 78 ++++++++++------ E07_StabArea/E07_StabArea_Router.py | 20 ++-- E07_StabArea/E07_StabArea_UI_Page.ts | 39 +++++--- E07_StabArea/E07_StabArea_UI_Style.css | 5 + .../master_template/report/stab_report.json | 2 +- .../report/stab_report_slots.md | 2 +- resources/tester/test_534_3_e05_report.py | 10 +- resources/tester/test_536_2_e07_area.py | 91 ++++++++++++++++--- ui_template/ui_template_locale_e07.ts | 24 +++-- 10 files changed, 210 insertions(+), 80 deletions(-) diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py index f62bb196e..843206f31 100644 --- a/E05_StabReport/E05_StabReport_Area.py +++ b/E05_StabReport/E05_StabReport_Area.py @@ -15,6 +15,18 @@ from E07_StabArea.E07_StabArea_Router import RESULT, load CLASS_NAMES = ("", "안정", "보통 안정", "준안정", "위험 하한", "위험 상한", "불안정") +def scenario_text(s: dict[str, Any]) -> str: + """시나리오 글 — 화면 시나리오 이름과 같은 말.""" + if s.get("scenario") == "saturated": + return "최악 — 완전 포화(m = 1 · 강우 극한)" + if s.get("scenario") == "sinmap": + return ( + f"표준 — SINMAP 습윤지수 W = min(R·a / (T·sinθ), 1) · " + f"T/R {s['tr_lo']:g}~{s['tr_hi']:g} m" + ) + return f"옛 조건({s.get('scenario')}) — E07 에서 [다시 해석]" + + def stamp(project_root: Path) -> list[int] | None: """해석 지문용 — 저장본 크기 · 고친 때.""" path = Path(project_root) / RESULT @@ -51,12 +63,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f f"φ {s['phi']:g}~{s['phi_hi']:g}° · 뿌리 점착 {s['c_root']:g} kPa · " f"토층 {s['soil_depth_m']:g} m", ) - rain = ( - "지표 포화(m = 1)" - if s["scenario"] == "saturated" - else f"강우 {s['rain_mm_h']:g} mm/h · 투수계수 {s['k_m_s']:g} m/s(정상류 포화비)" - ) - _set(sheet, "B7", rain) + _set(sheet, "B7", scenario_text(s)) _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) for k in range(1, 7): _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py index add8f867a..747dc3281 100644 --- a/E07_StabArea/E07_StabArea_Engine.py +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -5,12 +5,14 @@ 지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D8 흐름(웅덩이 채움 · 수신 셀 = B04 배수유역 함수 재사용) · 단위 집수면적 a = 상류 셀 수 × cell²/cell(등고선 폭 = cell) - 포화비 m = 1(지표 포화 · 기본) | min(R·a / (T·sinβ), 1) · T = K·z(정상류 · SINMAP · SHALSTAB) + 포화비 m 시나리오 둘 + sinmap(기본 · SINMAP 표준) = 습윤지수 W = min(x·a / sinβ, 1) · x = R/T ∈ [1/(T/R)_hi, 1/(T/R)_lo] + saturated(최악 · 강우 극한) = 지표 포화 m = 1 안전율 Fs = (c + c_r + [γt z(1−m) + (γsat−γw) m z] cos²β tanφ) / ([γt z(1−m) + γsat m z] sinβ cosβ) - 안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] 고른 분포 - Fs_min ≥ 1 → SI = Fs_min · Fs_max < 1 → SI = 0 - · 그 사이 → SI = P(Fs > 1)(두 고른 분포 합 · 닫힌 꼴) + 안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] · x ∈ [x_lo, x_hi] 고른 분포 + 하한 Fs_min = (c, tanφ, x_hi 가장 젖음) ≥ 1 → SI = Fs_min · 상한 Fs_max = (c_hi, tanφ_hi, x_lo) < 1 + → SI = 0 · 그 사이 → SI = P(Fs > 1)(c · tanφ 닫힌 꼴 · x 는 중점 적분 X_STEPS 마디) 등급 1 안정(SI ≥ 1.5) · 2 보통 안정(≥ 1.25) · 3 준안정(≥ 1.0) · 4 위험 하한(≥ 0.5) · 5 위험 상한(> 0) · 6 불안정(SI = 0) · 0 = 자료 없음 @@ -38,6 +40,7 @@ SI_EDGES = (1.5, 1.25, 1.0, 0.5) # 등급 1~4 아래 경계 · 5 = (0, 0.5) · CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended") RISK_CLASS = 4 # 이 등급부터 위험(SI < 1) FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞) +X_STEPS = 20 # SI 확률의 x = R/T 중점 적분 마디 @dataclass(frozen=True) @@ -52,9 +55,9 @@ class AreaParams: phi_hi: float c_root: float = 0.0 # 뿌리 점착(나지 = 0) soil_depth_m: float = 1.5 - scenario: str = "saturated" # saturated = 지표 포화 m = 1 · rain = 정상류 강우 - rain_mm_h: float = 30.0 - k_m_s: float = 1e-4 # 포화 투수계수 + scenario: str = "sinmap" # sinmap = 습윤지수(T/R 범위) · saturated = 지표 포화 m = 1(최악) + tr_lo: float = 2000.0 # T/R 아래(m · 젖은 쪽) + tr_hi: float = 3000.0 # T/R 위(m · 마른 쪽) # --------------------------------------------------------------------------- @@ -119,15 +122,23 @@ def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: # --------------------------------------------------------------------------- -def wetness(beta: np.ndarray, a: np.ndarray, p: AreaParams) -> np.ndarray: - """포화비 m(0~1) — 지표 포화 = 1 · 강우 = min(R·a/(T sinβ), 1).""" +def wetness(beta: np.ndarray, a: np.ndarray, x: float) -> np.ndarray: + """습윤지수 W = min(x·a / sinβ, 1) · x = R/T(1/m).""" + with np.errstate(divide="ignore", invalid="ignore"): + m = x * a / np.sin(beta) + return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + + +def x_range(p: AreaParams) -> tuple[float, float]: + """x = R/T 범위(마른 쪽, 젖은 쪽) — T/R 위 · 아래의 역수.""" + return 1.0 / max(p.tr_hi, p.tr_lo), 1.0 / p.tr_lo + + +def wet_m(beta: np.ndarray, a: np.ndarray, p: AreaParams, x: float | None = None) -> np.ndarray: + """시나리오 포화비 — saturated = 1 · sinmap = W(x · 없으면 가장 젖은 x_hi).""" if p.scenario == "saturated": return np.ones_like(beta) - recharge = p.rain_mm_h / 1000.0 / 3600.0 # m/s - transmissivity = p.k_m_s * p.soil_depth_m # m²/s - with np.errstate(divide="ignore", invalid="ignore"): - m = recharge * a / (transmissivity * np.sin(beta)) - return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + return wetness(beta, a, x_range(p)[1] if x is None else x) def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]: @@ -173,18 +184,33 @@ def _cdf_sum_uniform( return np.clip(np.nan_to_num(out, nan=0.0), 0.0, 1.0) -def stability_index(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: - """SINMAP 안정 지수 — Fs_min ≥ 1 → Fs_min · Fs_max < 1 → 0 · 그 사이 → P(Fs > 1).""" - a, b = _terms(beta, m, p) +def stability_index(beta: np.ndarray, area: np.ndarray, p: AreaParams) -> np.ndarray: + """SINMAP 안정 지수 — 하한 Fs_min ≥ 1 → Fs_min · 상한 Fs_max < 1 → 0 · 그 사이 → P(Fs > 1).""" t1, t2 = math.tan(math.radians(p.phi)), math.tan(math.radians(max(p.phi_hi, p.phi))) c1, c2 = p.c + p.c_root, max(p.c_hi, p.c) + p.c_root - flat = np.isinf(a) - a = np.where(flat, 0.0, a) - b = np.where(flat, 0.0, b) - lo, hi = c1 * a + t1 * b, c2 * a + t2 * b - prob = 1.0 - _cdf_sum_uniform(np.ones_like(a), c1 * a, c2 * a, t1 * b, t2 * b) + x_lo, x_hi = x_range(p) + + def ab(m: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + a, b = _terms(beta, m, p) + return np.where(np.isinf(a), 0.0, a), np.where(np.isinf(b), 0.0, b) + + a, b = ab(wet_m(beta, area, p, x_hi)) + lo = c1 * a + t1 * b + a, b = ab(wet_m(beta, area, p, x_lo)) + hi = c2 * a + t2 * b + xs = ( + [x_hi] + if p.scenario == "saturated" + else x_lo + (x_hi - x_lo) * (np.arange(X_STEPS) + 0.5) / X_STEPS + ) + prob = np.zeros_like(beta, dtype=float) + one = np.ones_like(prob) + for x in xs: + a, b = ab(wet_m(beta, area, p, x)) + prob += 1.0 - _cdf_sum_uniform(one, c1 * a, c2 * a, t1 * b, t2 * b) + prob /= len(xs) si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob)) - return np.where(flat, np.inf, si) + return np.where(beta < FLAT_SLOPE_RAD, np.inf, si) def classify(si: np.ndarray) -> np.ndarray: @@ -200,10 +226,10 @@ def classify(si: np.ndarray) -> np.ndarray: def analyze_grid(z: np.ndarray, cell_m: float, p: AreaParams) -> dict[str, np.ndarray]: """고른 격자 지형 → {slope(rad) · a · m · fs · si · cls} · 밖 = NaN / 0.""" beta = slope_rad(z, cell_m) - a = catchment_area(z, cell_m) if p.scenario == "rain" else np.full(z.shape, np.nan) - m = wetness(beta, a, p) + a = catchment_area(z, cell_m) if p.scenario != "saturated" else np.full(z.shape, np.nan) + m = wet_m(beta, a, p) # 가장 젖은 쪽(Fs = 하한과 같은 조건) fs = infinite_slope_fs(beta, m, p) - si = stability_index(beta, m, p) + si = stability_index(beta, a, p) nodata = np.isnan(beta) fs[nodata] = np.nan si[nodata] = np.nan diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index dfae623ff..21b512ec9 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -46,7 +46,7 @@ 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-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e07-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 RESULT = Path("E07_StabArea") / "_derived" / "area.json" DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) RANGE_C_KPA, RANGE_PHI_DEG = ( @@ -69,17 +69,19 @@ class AreaRequest(BaseModel): c_hi: float = Field(..., ge=0.0, le=500.0) phi: float = Field(..., ge=0.0, le=60.0) phi_hi: float = Field(..., ge=0.0, le=60.0) - c_root: float = Field(0.0, ge=0.0, le=50.0) + # 기본값 = 근거 후보(바꿀 수 있음 · 확정은 사용자) — 출처 = 지식DB 예정노선/02 §1.1 · + # SINMAP 2 설명서 기본 보정 · 국내 적용 연구(김동엽 2026 봉화 TRIGRS Cr 0~5 kPa · 우면산 1 kPa) + c_root: float = Field(1.0, ge=0.0, le=50.0) # 산림 뿌리 점착 · 나지 · 절성토면 = 0 soil_depth_m: float = Field(1.5, ge=0.1, le=10.0) - scenario: Literal["saturated", "rain"] = "saturated" - rain_mm_h: float = Field(30.0, ge=0.0, le=300.0) - k_m_s: float = Field(1e-4, ge=1e-8, le=1e-1) + scenario: Literal["sinmap", "saturated"] = "sinmap" # 표준(습윤지수) · 최악(완전 포화) + tr_lo: float = Field(2000.0, ge=1.0, le=1e5) # T/R(m) — SINMAP 기본 보정 2000~3000 + tr_hi: float = Field(3000.0, ge=1.0, le=1e5) force: bool = False @model_validator(mode="after") def _ranges(self) -> AreaRequest: - if self.c_hi < self.c or self.phi_hi < self.phi: - raise ValueError("범위 위가 아래보다 작습니다(c · φ).") + if self.c_hi < self.c or self.phi_hi < self.phi or self.tr_hi < self.tr_lo: + raise ValueError("범위 위가 아래보다 작습니다(c · φ · T/R).") return self @@ -167,8 +169,8 @@ def run( "c_root", "soil_depth_m", "scenario", - "rain_mm_h", - "k_m_s", + "tr_lo", + "tr_hi", ) out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys})) report(75, "노선 띠 · 측점") diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index f31216547..7e7726d2e 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -1,7 +1,7 @@ /* ============================================================================= * E07_StabArea_UI_Page.ts * E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow). - * 좌 = 토층 물성(E02 물성 고름 · 범위) · 강우(지표 포화 · 강우 강도) · 노선 띠 · [해석] · 진행률 + * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. @@ -67,8 +67,8 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ ["phi_hi", "E07_PhiHi", "soil"], ["c_root", "E07_CRoot", "soil"], ["soil_depth_m", "E07_Depth", "soil"], - ["rain_mm_h", "E07_Rain", "rain"], - ["k_m_s", "E07_K", "rain"], + ["tr_lo", "E07_TrLo", "rain"], + ["tr_hi", "E07_TrHi", "rain"], ["band_m", "E07_Band", "band"], ["cell_m", "E07_Cell", "band"], ]; @@ -100,9 +100,15 @@ function swatch(color: string): HTMLElement { return chip; } -function legend(layer: MapLayer): HTMLElement { +const SCENARIOS = ["sinmap", "saturated"]; +const scenarioName = (value: unknown): string => + SCENARIOS.includes(String(value)) ? t(`E07_Scenario_${value}` as LocaleKey) : String(value ?? ""); + +function legend(layer: MapLayer, scenario: unknown): HTMLElement { const box = el("div", { className: "e07-legend" }); box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); + if (scenario) + box.append(el("small", { className: "e07-legend__sub", text: scenarioName(scenario) })); const rows = layer === "cls" ? CLASS_KEYS.map((k, i) => ({ @@ -136,9 +142,11 @@ export function renderE07StabArea(root: HTMLElement): void { // ---- 가운데 ---- const map = createAreaMap(); + let overview: Overview | null = null; let layer: MapLayer = "cls"; const legendSlot = el("div", { className: "e07-legend-slot" }); - const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); + const paintLegend = (): void => + legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario)); const toggle = el("div", { className: "e07-toggle" }); const layerButtons = ( [ @@ -187,14 +195,12 @@ export function renderE07StabArea(root: HTMLElement): void { const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); const scenario = createSelectField({ label: t("E07_Scenario"), - options: [ - { value: "saturated", text: t("E07_Scenario_saturated") }, - { value: "rain", text: t("E07_Scenario_rain") }, - ], + options: SCENARIOS.map((value) => ({ value, text: scenarioName(value) })), compact: true, }); cards.soil.append(material.root); cards.rain.append(scenario.root); + const sourceHint = el("p", { className: "e07-hint", text: t("E07_Defaults_Source") }); for (const [key, label, card] of NUMBER_FIELDS) { const field = createInputField({ label: t(label), type: "number", compact: true }); field.input.step = "any"; @@ -232,13 +238,13 @@ export function renderE07StabArea(root: HTMLElement): void { cards.soil, cards.rain, cards.band, + sourceHint, progressBox, actions, ); attachCollapsible(panel); // ---- 상태 ---- - let overview: Overview | null = null; let running = false; function read(): Settings { @@ -251,14 +257,16 @@ export function renderE07StabArea(root: HTMLElement): void { function write(values: Settings): void { for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); material.select.value = String(values.material_id ?? ""); - scenario.select.value = String(values.scenario ?? "saturated"); + scenario.select.value = SCENARIOS.includes(String(values.scenario)) + ? String(values.scenario) + : SCENARIOS[0]; paintScenario(); remember(); } function paintScenario(): void { - const rain = scenario.select.value === "rain"; - for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain; + const wet = scenario.select.value === "sinmap"; + for (const key of ["tr_lo", "tr_hi"]) fields.get(key)!.root.hidden = !wet; map.setBand(Number(fields.get("band_m")!.input.value) || 100); } @@ -299,6 +307,7 @@ export function renderE07StabArea(root: HTMLElement): void { const s = result.summary; const list = el("dl", { className: "e07-summary" }); const pairs: [LocaleKey, string][] = [ + ["E07_Scenario", scenarioName(result.settings.scenario)], ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`], ["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`], @@ -404,6 +413,7 @@ export function renderE07StabArea(root: HTMLElement): void { map.setResult(overview.result ? decode(overview.result) : null); paintButtons(); paintResult(); + paintLegend(); } async function poll(): Promise { @@ -463,7 +473,8 @@ export function renderE07StabArea(root: HTMLElement): void { const cached = JSON.parse( sessionStorage.getItem(cacheKey(projectId)) ?? "null", ) as Settings | null; - write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults); + // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 + write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) }); 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/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css index ee82afbc2..55436dfab 100644 --- a/E07_StabArea/E07_StabArea_UI_Style.css +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -96,6 +96,11 @@ font-size: 12px; } +.e07-legend__sub { + max-width: 180px; + color: var(--color-text-muted); +} + .e07-legend__row { display: flex; align-items: center; diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 42a350b7e..0b20ea6d6 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -2731,7 +2731,7 @@ "서식": 5 }, "A7": { - "값": "강우 · 포화", + "값": "시나리오 · 포화", "서식": 4 }, "B7": { diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index b9bafb828..c2d8b4d7a 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -84,7 +84,7 @@ | 칸 | 내용 | | --- | --- | -| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 강우 · 포화 · 해석 때 글 | +| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 시나리오(표준 SINMAP 습윤지수 · 최악 완전 포화) · 해석 때 글 | | `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 | | 22 행부터 위험 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 가장 나쁜 등급 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 9c486114b..19896df36 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -239,9 +239,9 @@ def test_광역요약_E07_저장본(project): "phi_hi": 30.0, "c_root": 0.0, "soil_depth_m": 1.5, - "scenario": "saturated", - "rain_mm_h": 30.0, - "k_m_s": 1e-4, + "scenario": "sinmap", + "tr_lo": 2000.0, + "tr_hi": 3000.0, }, "computed_at": "2026-10-25T21:00:00", "stations": [ @@ -288,7 +288,9 @@ def test_광역요약_E07_저장본(project): assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 assert ( - s7["E22"]["값"] == "불안정" and s7["F23"]["값"] == 8.0 and s7["B7"]["값"].startswith("지표") + s7["E22"]["값"] == "불안정" + and s7["F23"]["값"] == 8.0 + and s7["B7"]["값"].startswith("표준 — SINMAP") ) vals = book["계산값"]["s7"] assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index b72bf11c4..f56853037 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -1,7 +1,8 @@ """536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. 무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · -D8 집수면적 견본(기운 평면 · V 골짜기) · 강우 포화비 · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. +D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · +가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. """ import base64 @@ -15,6 +16,7 @@ from E07_StabArea import E07_StabArea_Engine as engine from E07_StabArea import E07_StabArea_Router as router_mod SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) +SAT = dict(scenario="saturated") def _fs(beta_deg: float, m: float, **kw) -> float: @@ -36,7 +38,9 @@ def test_무한사면_해석해_건조_포화_점착(): def test_SINMAP_안정지수_닫힌꼴_표본(): - p = engine.AreaParams(gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0) + p = engine.AreaParams( + gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0, **SAT + ) beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) m = np.ones_like(beta) si = engine.stability_index(beta, m, p) @@ -55,7 +59,7 @@ def test_SINMAP_안정지수_닫힌꼴_표본(): assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 - q = engine.AreaParams(**SOIL) + q = engine.AreaParams(**SOIL, **SAT) fs = engine.infinite_slope_fs(beta, m, q) si0 = engine.stability_index(beta, m, q) assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) @@ -80,14 +84,75 @@ def test_D8_집수면적_기운평면_V골짜기(): assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 -def test_강우_포화비(): - p = engine.AreaParams(**SOIL, scenario="rain", rain_mm_h=36.0, k_m_s=1e-4, soil_depth_m=2.0) - beta = np.radians(np.array([30.0, 30.0])) - a = np.array([10.0, 1e6]) - m = engine.wetness(beta, a, p) - assert m[0] == pytest.approx(1e-5 * 10 / (2e-4 * 0.5)) # R a / (T sinβ) - assert m[1] == 1.0 - assert engine.wetness(beta, a, engine.AreaParams(**SOIL)).tolist() == [1.0, 1.0] +def test_습윤지수(): + beta = np.radians(np.array([30.0, 30.0, 0.0])) + a = np.array([100.0, 1e6, 50.0]) + w = engine.wetness(beta, a, 1 / 2000) + assert w[0] == pytest.approx(100 / (2000 * 0.5)) # R a / (T sinβ) + assert w[1] == 1.0 and w[2] == 1.0 # 넘침 · 평평 = 포화 + p = engine.AreaParams(**SOIL, tr_lo=2000, tr_hi=3000) + assert engine.x_range(p) == (1 / 3000, 1 / 2000) + assert engine.wet_m(beta, a, p).tolist() == w.tolist() # 기본 = 가장 젖은 쪽 + assert engine.wet_m(beta, a, engine.AreaParams(**SOIL, **SAT)).tolist() == [1.0] * 3 + + +SINMAP = dict(gamma_t=18.0, gamma_sat=18.0, c=2.0, c_hi=6.0, phi=30.0, phi_hi=40.0) + + +def _sinmap_fs(beta, a, c, tanphi, x, z=1.5, g=18.0): + """Pack 1998 식 — FS = [C + cosθ(1 − W r) tanφ] / sinθ · C = c / (h ρs g) · h = z cosθ.""" + w = min(x * a / math.sin(beta), 1.0) + h = z * math.cos(beta) + return (c / (h * g) + math.cos(beta) * (1 - w * 9.81 / g) * tanphi) / math.sin(beta) + + +def test_SI_하한_상한_해석해(): + """γt = γsat 이면 엔진 = SINMAP 원식 · 하한 = (c, φ 아래 · T/R 아래) · 상한 = (위 · T/R 위).""" + p = engine.AreaParams(**SINMAP, tr_lo=1000, tr_hi=3000) + t1, t2 = math.tan(math.radians(30)), math.tan(math.radians(40)) + beta = np.radians(np.array([15.0, 33.0, 38.0, 45.0, 70.0])) + area = np.array([20.0, 400.0, 300.0, 800.0, 5000.0]) + si = engine.stability_index(beta, area, p) + rng = np.random.default_rng(7) + n = 300_000 + c, t, x = rng.uniform(2, 6, n), rng.uniform(t1, t2, n), rng.uniform(1 / 3000, 1 / 1000, n) + seen = set() + for k, (b, ak) in enumerate(zip(beta, area)): + lo = _sinmap_fs(b, ak, 2.0, t1, 1 / 1000) + hi = _sinmap_fs(b, ak, 6.0, t2, 1 / 3000) + fs = engine.infinite_slope_fs( + beta[k : k + 1], engine.wet_m(beta[k : k + 1], area[k : k + 1], p), p + ) + assert fs[0] == pytest.approx(lo, rel=1e-12) # 결정 Fs = 하한 + if lo >= 1: + assert si[k] == pytest.approx(lo, rel=1e-12) + seen.add("lo") + elif hi < 1: + assert si[k] == 0.0 + seen.add("zero") + else: + w = np.minimum(x * ak / math.sin(b), 1) + h = 1.5 * math.cos(b) + mc = (c / (h * 18) + math.cos(b) * (1 - w * 9.81 / 18) * t) / math.sin(b) + assert si[k] == pytest.approx((mc > 1).mean(), abs=3e-3) # P(Fs > 1) · T/R 도 범위 + seen.add("prob") + assert seen == {"lo", "zero", "prob"} + + +def test_시나리오_둘_최악이_더_나쁨(): + beta = np.radians(np.linspace(5, 60, 56)) + area = np.full_like(beta, 50.0) + std = engine.stability_index(beta, area, engine.AreaParams(**SINMAP)) + worst = engine.stability_index(beta, area, engine.AreaParams(**SINMAP, **SAT)) + assert np.all(worst <= std + 1e-12) # 완전 포화 = 아래 경계 + assert (engine.classify(worst) >= engine.RISK_CLASS).sum() > ( + engine.classify(std) >= engine.RISK_CLASS + ).sum() + # 다 젖는 집수(x·a / sinβ ≥ 1) 면 두 시나리오가 같음 + big = np.full_like(beta, 1e7) + assert np.allclose( + engine.stability_index(beta, big, engine.AreaParams(**SINMAP)), worst, rtol=1e-12 + ) def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: @@ -126,7 +191,9 @@ def project(tmp_path, monkeypatch): def test_길_띠_측점표_저장본(project): - settings = {**router_mod.defaults(project), "band_m": 30.0, "cell_m": 2.0} + base = router_mod.defaults(project) + assert base["scenario"] == "sinmap" and (base["tr_lo"], base["tr_hi"]) == (2000.0, 3000.0) + settings = {**base, "band_m": 30.0, "cell_m": 2.0, "scenario": "saturated", "c_root": 0.0} assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 steps = [] summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts index 036187a13..cf7486a54 100644 --- a/ui_template/ui_template_locale_e07.ts +++ b/ui_template/ui_template_locale_e07.ts @@ -16,7 +16,7 @@ export const ui_locales_e07 = { ], E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E07_Card_Soil: ["토층 물성", "Soil layer"], - E07_Card_Rain: ["강우 · 포화", "Rain · saturation"], + E07_Card_Rain: ["시나리오 · 포화", "Scenario · saturation"], E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"], E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"], E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"], @@ -28,10 +28,16 @@ export const ui_locales_e07 = { E07_CRoot: ["뿌리 점착 (kPa)", "Root cohesion (kPa)"], E07_Depth: ["토층 두께 (m)", "Soil depth (m)"], E07_Scenario: ["시나리오", "Scenario"], - E07_Scenario_saturated: ["지표 포화 (m = 1)", "Fully saturated (m = 1)"], - E07_Scenario_rain: ["강우 강도 (정상류)", "Rainfall (steady state)"], - E07_Rain: ["강우 강도 (mm/h)", "Rainfall (mm/h)"], - E07_K: ["투수계수 (m/s)", "Hydraulic conductivity (m/s)"], + E07_Scenario_sinmap: [ + "표준 — SINMAP 습윤지수 (T/R 범위)", + "Standard — SINMAP wetness (T/R range)", + ], + E07_Scenario_saturated: [ + "최악 — 완전 포화 (m = 1 · 강우 극한)", + "Worst — fully saturated (m = 1 · extreme rain)", + ], + E07_TrLo: ["T/R 아래 (m · 젖은 쪽)", "T/R low (m · wetter)"], + E07_TrHi: ["T/R 위 (m · 마른 쪽)", "T/R high (m · drier)"], E07_Band: ["띠 폭 ± (m)", "Band half-width (m)"], E07_Cell: ["격자 (m)", "Cell (m)"], E07_Defaults: ["기본값", "Defaults"], @@ -63,8 +69,8 @@ export const ui_locales_e07 = { "The engine changed — re-analyze.", ], E07_Result_Note: [ - "문헌 추정치 · 현장 시험 없음. SI = c · φ 범위 안에서 Fs > 1 일 확률(SI ≥ 1 이면 가장 나쁜 값의 Fs). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.", - "Literature values, no field tests. SI = probability of Fs > 1 over the c · φ ranges (worst-case Fs when SI ≥ 1). Upslope area beyond the terrain grid is not counted.", + "문헌 추정치 · 현장 시험 없음 · 기본값은 바꿀 수 있음. 표준 = 포화비 W = min(R·a / (T·sinθ), 1). SI = c · φ · T/R 범위 안에서 Fs > 1 일 확률(하한 Fs ≥ 1 이면 그 값 · 상한 Fs < 1 이면 0). Fs = 가장 나쁜 쪽(c · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.", + "Literature values, no field tests; defaults are editable. Standard = wetness W = min(R·a / (T·sinθ), 1). SI = probability of Fs > 1 over the c · φ · T/R ranges (lower-bound Fs when ≥ 1, 0 when upper-bound Fs < 1). Fs = worst case (low c · φ, wettest). Upslope area beyond the terrain grid is not counted.", ], E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"], E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"], @@ -80,4 +86,8 @@ export const ui_locales_e07 = { E07_Col_SiMin: ["최소 SI", "Min SI"], E07_Col_Worst: ["나쁜 등급", "Worst class"], E07_Col_RiskArea: ["위험 m²", "Hazard m²"], + E07_Defaults_Source: [ + "기본값 = 근거 후보 · 바꿀 수 있음 — 물성 = E02 붕적토 · T/R 2,000~3,000 m = SINMAP 설명서 기본 보정 · 뿌리 점착 1 kPa = 국내 연구(산림 0~5 kPa · 나지 · 절성토면 0).", + "Defaults are evidence-based candidates and editable — soil = E02 colluvium · T/R 2,000–3,000 m = SINMAP manual default · root cohesion 1 kPa = Korean studies (forest 0–5 kPa · bare / cut slopes 0).", + ], } as const satisfies Record; From 0ac8bc0d9ffe9c20a1061eb695e41e14b7477829 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:22:25 +0900 Subject: [PATCH 25/34] =?UTF-8?q?fix(532-5):=20E03=20=E2=86=92=20E05=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=82=AC=EB=B3=B8=20=EC=93=B0=EA=B8=B0=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20result2d=20=EC=A7=80=EB=B0=B0=20=EB=B9=84?= =?UTF-8?q?=ED=83=88=20=EB=8D=A7=EC=B9=B8(fs=20/=20=EA=B8=B0=EC=A4=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E03 export_report · report_row · report_representatives · REPORT_REASONS 걷음 — E05 는 E03 정본(stab2d_summary · stab2d_station)만 읽음 · representative.json = overrides 만 - GET …/stab/result2d 측점 줄에 station_key · side · fs_min · fs_by_condition · method · verdict_overall 덧붙임(E04 2D 최소) — 지배 = 조건 × 비탈 중 fs / 기준 가장 낮은 것 · 조건별 verdict 칸은 E03 화면 몫이라 그대로 - 시험 test_532_5 — 옛 사본 꼴 시험을 E05 = E03 정본(측점 수 · N.G 수 · 지배 비탈 같음)으로 바꿈 · 지배 비탈 규칙(깎기 1.22/1.2 · 쌓기 1.25/1.3 → 쌓기 N.G · E05 _row2d 같음) Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_Router.py | 41 ++------------- E03_StabSection/E03_StabSection_View.py | 54 +++++++++----------- resources/tester/test_532_5_e03_seam.py | 61 ++++++++++++++++++++--- 3 files changed, 81 insertions(+), 75 deletions(-) diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index 7828b9e71..d7da920a5 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -2,7 +2,7 @@ - `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈 - `GET …/stab/progress` — 진행률 -- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수) + 횡단 없는 측점 +- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수 · 지배 비탈) + 횡단 없는 측점 - `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자 - `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json` - `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용) @@ -64,8 +64,6 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] KIND_STATION = worker.KIND_STATION KIND_SUMMARY = "stab2d_summary" REPRESENTATIVE = Path("E03_StabSection") / "representative.json" -#: E05 보고서가 읽는 꼴(`E05_StabReport_Build` · result2d.json · results/<측점>.json) -REPORT_DIR = Path("E03_StabSection") / "_derived" ENGINE_FILES = ( "E03_StabSection_Types.py", "E03_StabSection_Engine_Model.py", @@ -280,38 +278,9 @@ def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: "stations": rows, } write_derived(root, KIND_SUMMARY, plan["fp"], summary) - export_report(root, summary) return len(todo) -def export_report(root: Path, summary: dict[str, Any]) -> None: - """E05 보고서 입력 — 요약(측점 줄) · 대표 단면 측점 해석 결과 · representative.json `stations` - (E03 은 `overrides` 만 읽음). [해석] 끝 · [대표 단면 저장] 때 씀.""" - rows = [dict(r) for r in summary["stations"]] - overrides = _overrides(root) - view.mark_representative(rows, overrides) - folder = root / REPORT_DIR - atomic_write_json( - folder / "result2d.json", - { - "input_fingerprint": summary["fingerprint"], - "computed_at": summary["computed_at"], - "conditions": summary["conditions"], - "stations": [view.report_row(r) for r in rows], - }, - ) - reps = view.report_representatives(rows) - results = folder / "results" - for old in results.glob("*.json"): - old.unlink() - by_key = {r["key"]: r for r in rows} - for rep in reps: - saved = read_derived(root, KIND_STATION, by_key[rep["station_key"]]["fp"]) - if saved and saved["value"]["out"]: - atomic_write_json(results / f"{rep['station_key']}.json", saved["value"]["out"]) - atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides, "stations": reps}) - - def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]: """(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과).""" saved = read_derived(root, KIND_SUMMARY, fp) @@ -392,6 +361,7 @@ async def result2d( if not summary: rows = [view.station_row(_meta(item, {}), None, plan["factors"]) for item in plan["items"]] view.mark_representative(rows, _overrides(root)) + rows = [{**r, **view.governing_row(r)} for r in rows] return { "status": "none", "fresh": False, @@ -404,6 +374,7 @@ async def result2d( } rows = summary["stations"] view.mark_representative(rows, _overrides(root)) + rows = [{**r, **view.governing_row(r)} for r in rows] return { "status": "ok", "fresh": fresh, @@ -455,16 +426,12 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]: async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]: """대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만.""" root, _ = await e02._project(project_id) - plan = plan_run(root, _ground(root)) - known = {item["key"] for item in plan["items"]} + known = {item["key"] for item in plan_run(root, _ground(root))["items"]} unknown = sorted(set(payload.overrides) - known) if unknown: raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}") overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None} atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides}) - summary, _ = _latest_summary(root, plan["fp"]) - if summary: - export_report(root, summary) return {"overrides": overrides} diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py index df766049d..6e94d152e 100644 --- a/E03_StabSection/E03_StabSection_View.py +++ b/E03_StabSection/E03_StabSection_View.py @@ -130,44 +130,36 @@ def mark_representative(rows: list[dict[str, Any]], overrides: dict[str, bool]) } -#: 대표 단면 까닭 → E05 보고서 열쇠(`E05_StabReport_Build.REASONS`) -REPORT_REASONS = {"fill_height": "fill_10m", "cut_height": "cut_15m", "lowest": "lowest_fs"} - - -def report_row(row: dict[str, Any]) -> dict[str, Any]: - """E05 보고서가 읽는 측점 줄 — 지배 비탈(전 조건 최소 안전율 쪽) 역할 · 조건별 최소 · 판정.""" - worst = None - for by_side in row["cases"].values(): - for side, value in by_side.items(): - if value["fs"] is not None and (worst is None or value["fs"] < worst[0]): - worst = (value["fs"], side) - roles = {s["side"]: s["role"] for s in row["sides"]} - values = [v for v in row["min"].values() if v is not None] +def governing_row(row: dict[str, Any]) -> dict[str, Any]: + """측점 줄 덧칸(E04 · E05 같은 규칙) — 지배 = 조건 × 비탈 중 기준 대비 가장 낮은 것(fs / 기준) · + fs_min = 그 fs · 조건별 안전율 = 그 비탈 · 판정 = 조건 하나라도 N.G 면 ng.""" + roles = {s["side"]: s["role"] for s in row.get("sides") or []} + cases = row.get("cases") or {} + worst = min( + ( + (c["fs"] / c["required"], side, c["fs"]) + for sides in cases.values() + for side, c in sides.items() + if c.get("fs") is not None and c.get("required") + ), + default=None, + ) + side = worst[1] if worst else None + verdicts = set((row.get("verdict") or {}).values()) + values = [v for v in (row.get("min") or {}).values() if v is not None] # 기준 없을 때 return { "station_key": row["key"], "chainage_m": row["chainage_m"], - "label": row.get("label"), - "side": roles.get(worst[1]) if worst else None, - "fs_min": min(values) if values else None, - "fs_by_condition": dict(row["min"]), + "side": roles.get(side), + "fs_min": worst[2] if worst else min(values, default=None), + "fs_by_condition": { + cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides + }, "method": PRIMARY, - "verdict": overall_verdict(row), - "walls": row.get("walls") or 0, + "verdict_overall": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, } -def report_representatives(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - """E05 대표 단면 목록(켠 것만) — `mark_representative` 뒤 행.""" - out = [] - for row in rows: - rep = row.get("representative") or {} - if rep.get("on"): - auto = rep.get("auto") or [] - reason = "user" if rep.get("user") or not auto else REPORT_REASONS.get(auto[0], auto[0]) - out.append({"station_key": row["key"], "reason": reason, "enabled": True}) - return out - - def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, Any]: """머리 요약 — 조건별 최소 안전율 · N.G 측점 수.""" out: dict[str, Any] = {"stations": len(rows), "errors": sum(1 for r in rows if r["error"])} diff --git a/resources/tester/test_532_5_e03_seam.py b/resources/tester/test_532_5_e03_seam.py index 9c2bcaa08..4dd5b86f2 100644 --- a/resources/tester/test_532_5_e03_seam.py +++ b/resources/tester/test_532_5_e03_seam.py @@ -5,6 +5,8 @@ 측점 보기에 벽 폴리곤 · 목록 벽 수 · 벽 있는 측점만 지문이 바뀜 - 지하수: E02 우기 갈래를 바꾸면 측점 지문이 바뀜 · 측점 보기 수위선 = 고른 갈래 - 빠진 횡단: 횡단 파일이 없으면 result2d · stab/ground 의 missing_stations 에 그 측점 +- E05 이음매: E05 가 E03 정본만 읽음(사본 없음) · 측점 수 · N.G 수 · 지배 비탈이 E03 과 같음 +- result2d 덧칸: 지배 비탈 = fs / 기준 가장 낮은 쪽(깎기 1.22/1.2 · 쌓기 1.25/1.3 → 쌓기 · N.G) ./venv/Scripts/python.exe -m pytest resources/tester/test_532_5_e03_seam.py -q """ @@ -32,6 +34,7 @@ from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402 from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402 from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402 from E03_StabSection import E03_StabSection_Router as router # noqa: E402 +from E03_StabSection import E03_StabSection_View as view # noqa: E402 from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402 from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402 @@ -190,27 +193,71 @@ def test_빠진_횡단_목록(project): assert asyncio.run(router.result2d(UUID(PID), USER))["missing_stations"] == [] -def test_E05_가_E03_저장본으로_보고서(project): - """E03 [해석] 이 쓴 E05 꼴(result2d.json · results/ · representative stations)로 E05 가 돎.""" +def test_E05_가_E03_정본으로_보고서(project): + """E03 [해석] 정본(stab2d_summary · stab2d_station)으로 E05 가 돎 — 사본 안 씀 · 수가 같음.""" from E05_StabReport import E05_StabReport_Build as build _analyze() + assert not (project / "E03_StabSection" / "_derived").exists() # 옛 사본 꼴 없음 + body = asyncio.run(router.result2d(UUID(PID), USER)) inputs = build.gather(project) rows = inputs["result2d"]["stations"] - assert len(rows) == 2 and WALL_KEY in {r["station_key"] for r in rows} + assert len(rows) == len(body["stations"]) == 2 + assert WALL_KEY in {r["station_key"] for r in rows} + ng = sum(1 for r in body["stations"] if r["verdict_overall"] == "ng") + assert sum(1 for r in rows if r["verdict"] == "ng") == ng + e03 = {r["station_key"]: r for r in body["stations"]} for r in rows: - assert r["side"] in ("cut", "fill") and r["verdict"] in ("ok", "ng") - assert r["fs_min"] == min(v for v in r["fs_by_condition"].values() if v is not None) + mine = e03[r["station_key"]] + assert r["side"] == mine["side"] in ("cut", "fill") + assert r["fs_by_condition"] == mine["fs_by_condition"] + assert r["verdict"] == mine["verdict_overall"] in ("ok", "ng") reps = inputs["representatives"] assert reps and all(rep["result"] and rep["result"]["cases"] for rep in reps) book = build.form() jobs = build.build_program(book, inputs) assert len([k for k, svg in jobs if svg.startswith("= len(reps) - # 대표 단면 끔 → E05 목록에서 빠짐 · E03 overrides 는 그대로 읽힘 + # 대표 단면 끔 → E05 목록에서 빠짐 · representative.json = overrides 만 key = reps[0]["station_key"] asyncio.run( router.put_representative(UUID(PID), router.RepresentativeRequest(overrides={key: False})) ) assert key not in [r["station_key"] for r in build.gather(project)["representatives"]] - assert router._overrides(project) == {key: False} + saved = json.loads((project / router.REPRESENTATIVE).read_text("utf-8")) + assert saved == {"overrides": {key: False}} + + +def _case(fs, need): + return {"fs": fs, "required": need, "ok": fs >= need} + + +def test_result2d_지배_비탈_기준_대비(): + """깎기 1.22/1.2(O.K) · 쌓기 1.25/1.3(N.G) — fs 만 낮은 깎기가 아니라 쌓기가 지배 · N.G.""" + from E05_StabReport import E05_StabReport_Build as build + + row = { + "key": "10.00", + "chainage_m": 10.0, + "sides": [{"side": "left", "role": "cut"}, {"side": "right", "role": "fill"}], + "cases": { + "dry": {"left": _case(1.6, 1.5), "right": _case(1.7, 1.5)}, + "wet": {"left": _case(1.22, 1.2), "right": _case(1.25, 1.3)}, + }, + "min": {"dry": 1.6, "wet": 1.22}, + "verdict": {"dry": "ok", "wet": "ng"}, + } + out = view.governing_row(row) + assert out["side"] == "fill" and out["fs_min"] == 1.25 + assert out["fs_by_condition"] == {"dry": 1.7, "wet": 1.25} + assert out["verdict_overall"] == "ng" and out["method"] == "bishop" + e05 = build._row2d(row, "bishop") + assert (e05["side"], e05["fs_by_condition"], e05["verdict"]) == ( + out["side"], + out["fs_by_condition"], + out["verdict_overall"], + ) + row["verdict"] = {"dry": "ok", "wet": "ok"} + assert view.governing_row(row)["verdict_overall"] == "ok" + bare = {**row, "cases": {"dry": {"left": {"fs": 1.4, "required": None}}}} + assert view.governing_row(bare)["side"] is None and view.governing_row(bare)["fs_min"] == 1.22 From 292dfc9e37c6ac8070fd4bd84c3137aa7880cdd5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:27:02 +0900 Subject: [PATCH 26/34] =?UTF-8?q?fix(528-6):=20D=20=C2=B7=20E=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B0=80=EB=B3=8D=EA=B2=8C=20=E2=80=94=20B=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D(10.76=20MB)=20=EB=B3=B5=EC=82=AC=20=C2=B7=20?= =?UTF-8?q?B0x=20=EB=B9=88=20=ED=8F=B4=EB=8D=94=20=EC=97=86=EC=9D=8C(D=20?= =?UTF-8?q?=3D=20D0x=20+=20B04=5FPreProcess=20=C2=B7=20E=20=3D=20E0x)=20?= =?UTF-8?q?=C2=B7=20=EA=B2=BD=EB=A1=9C=20=ED=92=80=EC=9D=B4=EA=B0=80=20D?= =?UTF-8?q?=20=C2=B7=20E=20=EB=A3=A8=ED=8A=B8=EC=97=90=20B=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=20=EB=A7=89=EC=9D=8C=20=C2=B7=20ROAD=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=8B=9C=ED=97=98(?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=C2=B7=20ROA?= =?UTF-8?q?D=20=EB=B0=B0=EC=B9=98=20=C2=B7=20=ED=92=80=EC=9D=B4=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9C=80)(=EC=9A=94=EA=B5=AC=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- common_util/common_util_project_kind.py | 21 +++++++------ common_util/common_util_storage.py | 9 +++++- resources/tester/test_528_project_groups.py | 35 +++++++++++++++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py index 2ebca164c..e09facfbe 100644 --- a/common_util/common_util_project_kind.py +++ b/common_util/common_util_project_kind.py @@ -1,25 +1,24 @@ """종류별 프로젝트 행 만들기 — D 예정노선 · E 안정성평가(요구 345). -임도설계(ROAD)는 B02 그대로. 저장소 경로 · 초기 폴더는 B02 함수를 그대로 씀. +임도설계(ROAD)는 B02 그대로. 저장소 경로는 B02 함수를 씀 · 초기 폴더는 종류 단계만 +(B 양식 복사 · B0x 빈 폴더 없음 · D 는 지형 배치 B04_PreProcess 더함). """ from __future__ import annotations from datetime import datetime +from pathlib import Path from typing import Any from uuid import uuid4 -from B02_ProjRegister.B02_ProjRegister_Repository import ( - _build_project_storage, - _initialize_project_storage, -) +from B02_ProjRegister.B02_ProjRegister_Repository import _build_project_storage from common_util.common_util_audit import record_audit from common_util.common_util_storage import get_project_stage_path from common_util.common_util_workflow_state import initialize_project_stages from config.config_db import get_db_pool KIND_STAGE_DIRS = { - "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), + "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm", "B04_PreProcess"), "STABILITY": ( "E02_StabGround", "E03_StabSection", @@ -31,6 +30,12 @@ KIND_STAGE_DIRS = { } +def init_kind_storage(project_root: Path, kind: str) -> None: + """종류 단계 폴더만 — B 양식 · B0x 빈 폴더 · workflow · manifest 없음.""" + for stage_dir in KIND_STAGE_DIRS[kind]: + get_project_stage_path(str(project_root), stage_dir) + + async def create_kind_project( *, request: Any | None, @@ -99,9 +104,7 @@ async def create_kind_project( resource_ref=project_id, request=request, ) - _initialize_project_storage(project_root, project_id) - for stage_dir in KIND_STAGE_DIRS[kind]: - get_project_stage_path(str(project_root), stage_dir) + init_kind_storage(project_root, kind) await connection.commit() except Exception: await connection.rollback() diff --git a/common_util/common_util_storage.py b/common_util/common_util_storage.py index 59b65ffae..1d006136e 100644 --- a/common_util/common_util_storage.py +++ b/common_util/common_util_storage.py @@ -20,6 +20,8 @@ PROJECT_STORAGE_LAYOUT_V2 = ( ("B08_Quantity", "quantities"), ("B09_Estimation", "v1"), ) +#: D · E 프로젝트 표지 — 생성 때 만드는 단계 폴더(common_util_project_kind) +KIND_STAGE_MARKERS = tuple(sorted(s for s in PROJECT_STORAGE_STAGE_DIRS if s[0] in "DE")) def get_project_stage_path(project_root: str, stage: str) -> str: @@ -37,8 +39,13 @@ def get_project_stage_path(project_root: str, stage: str) -> str: def ensure_project_storage_layout(project_root: str) -> None: - """신규 워크플로우 저장소 하위 폴더를 누락분만 생성한다.""" + """신규 워크플로우 저장소 하위 폴더를 누락분만 생성한다. + + D · E 프로젝트(D0x · E0x 단계 폴더가 있는 루트)는 B 폴더를 만들지 않음 — 쓰는 곳이 스스로 만듦. + """ root = os.path.abspath(project_root) + if any(os.path.isdir(os.path.join(root, stage)) for stage in KIND_STAGE_MARKERS): + return for stage, subdir in PROJECT_STORAGE_LAYOUT_V2: if stage not in PROJECT_STORAGE_STAGE_DIRS: raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}") diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py index aedfd6fe7..62968e798 100644 --- a/resources/tester/test_528_project_groups.py +++ b/resources/tester/test_528_project_groups.py @@ -6,6 +6,7 @@ - 공유 판정 — 지정 회사 GET 200 · PUT 403 · 미지정 403 · 같은 회사 그대로 - 가입 — go.kr = 회사 「산림청」 · ACTIVE · 화면이 보낸 회사 값 무시 · 다른 메일 = NO_COMPANY - 종류별 단계 시드 · storage 허용 목록 +- 528-6 D · E 저장소 = 종류 단계만(B 양식 · B0x 빈 폴더 없음 · D 는 B04_PreProcess) · ROAD 그대로 """ import asyncio @@ -17,6 +18,7 @@ from A07_Register import A07_Register_Router as reg_router from A07_Register.A07_Register_Schema import RegisterRequest from common_util import common_util_auth as auth from common_util import common_util_auth_repository as auth_repo +from common_util.common_util_project_kind import KIND_STAGE_DIRS, init_kind_storage from common_util.common_util_workflow_state import initialize_project_stages from config.config_system import PROJECT_STORAGE_STAGE_DIRS from D01_PlanRegister import D01_PlanRegister_Router as d01 @@ -269,3 +271,36 @@ def test_stage_seed_by_kind(kind, count): def test_storage_dirs_allowed(): for name in ("D02_PlanBase", "D04_PlanConfirm", "E02_StabGround", "E05_StabReport"): assert name in PROJECT_STORAGE_STAGE_DIRS + + +@pytest.mark.parametrize("kind", ["PLAN", "STABILITY"]) +def test_kind_storage_light(tmp_path, kind): + init_kind_storage(tmp_path, kind) + names = {p.name for p in tmp_path.iterdir()} + assert names == set(KIND_STAGE_DIRS[kind]) + assert ("B04_PreProcess" in names) == (kind == "PLAN") + assert not [p for p in tmp_path.rglob("*") if p.is_file()] + + +def test_road_storage_unchanged(tmp_path, monkeypatch): + from B02_ProjRegister import B02_ProjRegister_Repository as b02 + + seeded = [] + monkeypatch.setattr(b02, "seed_project", seeded.append) + b02._initialize_project_storage(tmp_path, "p1") + assert seeded == [tmp_path] + assert (tmp_path / "B09_Estimation" / "v1").is_dir() + assert (tmp_path / "project_manifest.json").is_file() + + +@pytest.mark.parametrize("kind", ["PLAN", "STABILITY", None]) +def test_resolve_layout_skips_kind(tmp_path, kind): + from common_util.common_util_storage import ensure_project_storage_layout + + if kind: + init_kind_storage(tmp_path, kind) + before = {p.name for p in tmp_path.iterdir()} + ensure_project_storage_layout(str(tmp_path)) + after = {p.name for p in tmp_path.iterdir()} + assert (after == before) == bool(kind) + assert ("B09_Estimation" in after) == (kind is None) From 597aa687520867c4120f82d3971cd2987dabb5cd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:27:20 +0900 Subject: [PATCH 27/34] =?UTF-8?q?feat(536-2):=20E07=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20=EC=9C=84=ED=97=98=20=EA=B8=B0=EC=A4=80=20=E2=80=94=20?= =?UTF-8?q?=EC=98=81=ED=96=A5=20=EA=B5=AC=EC=97=AD=20=EC=95=88=20SI<1=20?= =?UTF-8?q?=EC=85=80=20=EB=B9=84=EC=9C=A8=EB=A1=9C=20=EB=82=AE=EC=9D=8C(<5?= =?UTF-8?q?=20%)=20=C2=B7=20=EB=B3=B4=ED=86=B5(5~20=20%)=20=C2=B7=20?= =?UTF-8?q?=EB=86=92=EC=9D=8C(=E2=89=A520=20%)=20=C2=B7=20=EB=AC=B8?= =?UTF-8?q?=ED=84=B1=20=EB=91=98=20=ED=99=94=EB=A9=B4=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B9=B8=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=ED=91=9C=20?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=20=C2=B7=20=EB=93=B1=EA=B8=89=20=EC=83=89=20?= =?UTF-8?q?=C2=B7=20E05=20=EA=B4=91=EC=97=AD=EC=9A=94=EC=95=BD=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=93=B1=EA=B8=89=20=EC=88=98(=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- E05_StabReport/E05_StabReport_Area.py | 23 +- E07_StabArea/E07_StabArea_Engine.py | 25 +- E07_StabArea/E07_StabArea_Router.py | 21 +- E07_StabArea/E07_StabArea_UI_Page.ts | 32 +- E07_StabArea/logic/E07_StabArea_Raster.ts | 13 +- .../master_template/report/stab_report.json | 4 +- .../report/stab_report_slots.md | 5 +- resources/tester/test_534_3_e05_report.py | 445 +----------------- resources/tester/test_536_2_e07_area.py | 226 +-------- ui_template/ui_template_locale_e07.ts | 17 +- 10 files changed, 106 insertions(+), 705 deletions(-) diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py index 843206f31..810d7c93e 100644 --- a/E05_StabReport/E05_StabReport_Area.py +++ b/E05_StabReport/E05_StabReport_Area.py @@ -9,10 +9,10 @@ from __future__ import annotations from pathlib import Path from typing import Any -from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, RISK_CLASS +from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS from E07_StabArea.E07_StabArea_Router import RESULT, load -CLASS_NAMES = ("", "안정", "보통 안정", "준안정", "위험 하한", "위험 상한", "불안정") +LEVEL_NAMES = {"low": "낮음", "mid": "보통", "high": "높음"} def scenario_text(s: dict[str, Any]) -> str: @@ -50,10 +50,13 @@ def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) - def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None: - """s7 — 조건 넷 · 등급별 면적 여섯 · 위험 측점(22 행부터 한 줄씩).""" + """s7 — 조건 넷 · 등급별 면적 여섯 · 측점 등급별 수 · 보통 · 높음 측점(22 행부터 한 줄씩).""" if not area: _set(sheet, "B5", "광역 해석 전 — E07 광역에서 [해석] 뒤 [보고서 갱신]") return + if "station_levels" not in area["summary"]: + _set(sheet, "B5", "옛 판 저장본 — E07 광역에서 [다시 해석] 뒤 [보고서 갱신]") + return s, summary = area["settings"], area["summary"] _set(sheet, "B5", f"중심선 ± {s['band_m']:g} m · 격자 {s['cell_m']:g} m") _set( @@ -67,7 +70,17 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) for k in range(1, 7): _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) - rows = [r for r in area["stations"] if r.get("worst_class", 0) >= RISK_CLASS] + lo, hi = s["level_lo_pct"], s["level_hi_pct"] + bounds = {"low": f"< {lo:g} %", "mid": f"{lo:g} ~ {hi:g} %", "high": f"≥ {hi:g} %"} + for col, text in zip("EFG", ("측점 등급", "SI<1 셀 비율", "측점 수")): + _set(sheet, f"{col}11", text, 6) + for i, key in enumerate(LEVEL_KEYS): + _set(sheet, f"E{12 + i}", LEVEL_NAMES[key], 7) + _set(sheet, f"F{12 + i}", bounds[key], 7) + _set(sheet, f"G{12 + i}", int(summary["station_levels"][key]), 14) + _set(sheet, "A20", f"3. 위험 측점 — 보통 · 높음 (영향 구역 안 SI < 1 셀 비율 {lo:g} % 이상)") + _set(sheet, "E21", "측점 등급 (SI<1 비율)") + rows = [r for r in area["stations"] if r.get("level") in ("mid", "high")] template = {col: dict(sheet["칸"].get(f"{col}22") or {}) for col in "ABCDEFG"} if not rows: _set(sheet, "A22", "위험 측점 없음") @@ -80,6 +93,6 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f _set(sheet, f"B{r}", ch) _set(sheet, f"C{r}", float(row["fs_min"])) _set(sheet, f"D{r}", float(row["si_min"])) - _set(sheet, f"E{r}", CLASS_NAMES[int(row["worst_class"])]) + _set(sheet, f"E{r}", f"{LEVEL_NAMES[row['level']]} ({row['risk_ratio'] * 100:.1f} %)") _set(sheet, f"F{r}", float(row["risk_area_m2"])) _set(sheet, f"G{r}", float(row["slope_max_deg"])) diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py index 747dc3281..1ff42172b 100644 --- a/E07_StabArea/E07_StabArea_Engine.py +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -39,6 +39,9 @@ GAMMA_W = 9.81 # kN/m³ SI_EDGES = (1.5, 1.25, 1.0, 0.5) # 등급 1~4 아래 경계 · 5 = (0, 0.5) · 6 = 0 CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended") RISK_CLASS = 4 # 이 등급부터 위험(SI < 1) +LEVEL_KEYS = ("low", "mid", "high") # 측점 등급 — 영향 구역 안 위험 셀 비율 기준 +LEVEL_LO_PCT = 5.0 # 낮음 < 이 % ≤ 보통 +LEVEL_HI_PCT = 20.0 # 보통 < 이 % ≤ 높음(이 % 이상 = 높음) FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞) X_STEPS = 20 # SI 확률의 x = R/T 중점 적분 마디 @@ -263,15 +266,28 @@ def band_cells( return (dist <= half_m).reshape(shape), dense[at, 2].reshape(shape) +def station_level( + risk_cells: int, cells: int, lo_pct: float = LEVEL_LO_PCT, hi_pct: float = LEVEL_HI_PCT +) -> str: + """측점 등급 — 위험(SI < 1) 셀 비율 < lo_pct 낮음 · lo_pct 이상 hi_pct 미만 보통 · + hi_pct 이상 높음. + (정수 곱 비교 — 문턱 경계에서 소수 오차 없음)""" + if risk_cells * 100 >= hi_pct * cells: + return "high" + return "mid" if risk_cells * 100 >= lo_pct * cells else "low" + + def station_table( chainages: list[float], cell_chain: np.ndarray, inside: np.ndarray, out: dict[str, np.ndarray], cell_m: float, + lo_pct: float = LEVEL_LO_PCT, + hi_pct: float = LEVEL_HI_PCT, ) -> list[dict[str, Any]]: - """측점마다 맡은 띠 셀(이웃 측점과 중간 누가로 나눔) — - 최소 Fs · SI · 가장 나쁜 등급 · 위험 면적.""" + """측점마다 맡은 띠 셀(이웃 측점과 중간 누가로 나눔 = 앞뒤 ½ 간격 × 띠 폭) — + 최소 Fs · SI · 가장 나쁜 등급 · 위험 면적 · 위험 셀 비율 · 측점 등급.""" chs = np.asarray(sorted(chainages), float) if not len(chs): return [] @@ -288,6 +304,7 @@ def station_table( cls = out["cls"][sel] fs = out["fs"][sel] si = out["si"][sel] + risk = int((cls >= RISK_CLASS).sum()) rows.append( { "chainage_m": float(ch), @@ -295,7 +312,9 @@ def station_table( "fs_min": round(float(np.min(fs)), 3), "si_min": round(float(np.min(si)), 3), "worst_class": int(cls.max()), - "risk_area_m2": round(float((cls >= RISK_CLASS).sum()) * cell_m**2, 1), + "risk_area_m2": round(risk * cell_m**2, 1), + "risk_ratio": round(risk / n, 4), + "level": station_level(risk, n, lo_pct, hi_pct), "slope_max_deg": round(float(np.degrees(np.nanmax(out["slope"][sel]))), 1), } ) diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index 21b512ec9..159cf7d51 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -46,7 +46,7 @@ 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-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e07-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 RESULT = Path("E07_StabArea") / "_derived" / "area.json" DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) RANGE_C_KPA, RANGE_PHI_DEG = ( @@ -76,10 +76,15 @@ class AreaRequest(BaseModel): scenario: Literal["sinmap", "saturated"] = "sinmap" # 표준(습윤지수) · 최악(완전 포화) tr_lo: float = Field(2000.0, ge=1.0, le=1e5) # T/R(m) — SINMAP 기본 보정 2000~3000 tr_hi: float = Field(3000.0, ge=1.0, le=1e5) + # 측점 등급 문턱(%) — 영향 구역 안 SI < 1 셀 비율 · 브레인 정함(사용자 확정 전) + level_lo_pct: float = Field(engine.LEVEL_LO_PCT, gt=0.0, le=100.0) + level_hi_pct: float = Field(engine.LEVEL_HI_PCT, gt=0.0, le=100.0) force: bool = False @model_validator(mode="after") def _ranges(self) -> AreaRequest: + if self.level_hi_pct < self.level_lo_pct: + raise ValueError("측점 등급 문턱: 높음이 보통보다 작습니다.") if self.c_hi < self.c or self.phi_hi < self.phi or self.tr_hi < self.tr_lo: raise ValueError("범위 위가 아래보다 작습니다(c · φ · T/R).") return self @@ -178,7 +183,15 @@ def run( inside, chain = engine.band_cells( z.shape, x0, y0, cell, _route_line(root), float(settings["band_m"]) ) - rows = engine.station_table([s["chainage_m"] for s in stations], chain, inside, out, cell) + rows = engine.station_table( + [s["chainage_m"] for s in stations], + chain, + inside, + out, + cell, + float(settings["level_lo_pct"]), + float(settings["level_hi_pct"]), + ) labels = {round(float(s["chainage_m"]), 3): s.get("label") for s in stations} for row in rows: row["label"] = labels.get(round(row["chainage_m"], 3)) @@ -198,9 +211,7 @@ def run( sum(v for k, v in areas.items() if engine.CLASS_KEYS.index(k) >= engine.RISK_CLASS), 1 ), "fs_min": round(float(np.min(out["fs"][have])), 3) if have.any() else None, - "risk_stations": [ - r["chainage_m"] for r in rows if r.get("worst_class", 0) >= engine.RISK_CLASS - ], + "station_levels": {k: sum(r.get("level") == k for r in rows) for k in engine.LEVEL_KEYS}, "stations": len(rows), } doc = { diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index 7e7726d2e..e23acd3bb 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -26,7 +26,8 @@ import { CLASS_COLORS, CLASS_KEYS, FS_STEPS, - RISK_CLASS, + LEVEL_COLORS, + LEVEL_KEYS, decode, type AreaResult, } from "./logic/E07_StabArea_Raster"; @@ -71,6 +72,8 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ ["tr_hi", "E07_TrHi", "rain"], ["band_m", "E07_Band", "band"], ["cell_m", "E07_Cell", "band"], + ["level_lo_pct", "E07_LevelLo", "band"], + ["level_hi_pct", "E07_LevelHi", "band"], ]; const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; @@ -310,7 +313,10 @@ export function renderE07StabArea(root: HTMLElement): void { ["E07_Scenario", scenarioName(result.settings.scenario)], ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`], - ["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`], + [ + "E07_Sum_Levels", + `${LEVEL_KEYS.map((k) => `${t(`E07_Level_${k}` as LocaleKey)} ${s.station_levels[k]}`).join(" · ")} / ${s.stations}`, + ], [ "E07_Sum_Covered", `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`, @@ -357,26 +363,28 @@ export function renderE07StabArea(root: HTMLElement): void { children: [ el("th", { text: t("E07_Col_Station") }), el("th", { text: t("E07_Col_FsMin") }), - el("th", { text: t("E07_Col_SiMin") }), - el("th", { text: t("E07_Col_Worst") }), + el("th", { text: t("E07_Col_Ratio") }), + el("th", { text: t("E07_Col_Level") }), el("th", { text: t("E07_Col_RiskArea") }), ], }), ...result.stations.map((r) => { - const worst = el("td"); - if (r.worst_class) - worst.append( - swatch(CLASS_COLORS[r.worst_class - 1]), - el("span", { text: t(`E07_Class_${CLASS_KEYS[r.worst_class - 1]}` as LocaleKey) }), + const level = el("td"); + if (r.level) + level.append( + swatch(LEVEL_COLORS[r.level]), + el("span", { text: t(`E07_Level_${r.level}` as LocaleKey) }), ); const tr = el("tr", { - className: (r.worst_class ?? 0) >= RISK_CLASS ? "is-risk" : "", + className: r.level === "high" ? "is-risk" : "", attrs: { "data-chainage": String(r.chainage_m) }, children: [ el("td", { text: shortLabel(r.label, r.chainage_m) }), el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }), - el("td", { text: r.si_min === undefined ? "-" : r.si_min.toFixed(2) }), - worst, + el("td", { + text: r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1), + }), + level, el("td", { text: r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), diff --git a/E07_StabArea/logic/E07_StabArea_Raster.ts b/E07_StabArea/logic/E07_StabArea_Raster.ts index a9a00b8e7..88ad25692 100644 --- a/E07_StabArea/logic/E07_StabArea_Raster.ts +++ b/E07_StabArea/logic/E07_StabArea_Raster.ts @@ -13,6 +13,8 @@ export interface Grid { ny: number; } +export type LevelKey = "low" | "mid" | "high"; + export interface StationRow { chainage_m: number; label: string | null; @@ -21,6 +23,8 @@ export interface StationRow { si_min?: number; worst_class?: number; risk_area_m2?: number; + risk_ratio?: number; + level?: LevelKey; slope_max_deg?: number; } @@ -30,7 +34,7 @@ export interface AreaSummary { covered_area_m2: number; risk_area_m2: number; fs_min: number | null; - risk_stations: number[]; + station_levels: Record; stations: number; } @@ -62,6 +66,13 @@ export interface Decoded { /** 등급 열쇠(서버 CLASS_KEYS 차례 · 1~6) */ export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const; export const RISK_CLASS = 4; +/** 측점 등급(서버 LEVEL_KEYS 차례) · 색 */ +export const LEVEL_KEYS: readonly LevelKey[] = ["low", "mid", "high"]; +export const LEVEL_COLORS: Record = { + low: "#1a9850", + mid: "#fee08b", + high: "#d73027", +}; /** 등급 색 1~6 — SINMAP 관례(초록 → 빨강) */ export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"]; diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 0b20ea6d6..469361b41 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -2926,7 +2926,7 @@ "서식": 16 }, "A20": { - "값": "3. 위험 측점 (SI < 1 셀이 있는 측점)", + "값": "3. 위험 측점 — 보통 · 높음 (영향 구역 안 SI < 1 셀 비율 5 % 이상)", "서식": 3 }, "B20": { @@ -2967,7 +2967,7 @@ "서식": 6 }, "E21": { - "값": "가장 나쁜 등급", + "값": "측점 등급 (SI<1 비율)", "서식": 6 }, "F21": { diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index c2d8b4d7a..52bb6e56a 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -86,6 +86,7 @@ | --- | --- | | `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 시나리오(표준 SINMAP 습윤지수 · 최악 완전 포화) · 해석 때 글 | | `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 | -| 22 행부터 위험 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 가장 나쁜 등급 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | +| `E11`~`G14` | 측점 등급 표 — 낮음 · 보통 · 높음별 SI<1 셀 비율 기준 글(7) · 측점 수(14) | +| 22 행부터 보통 · 높음 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 측점 등급 + SI<1 비율 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | -- 22 행 서식을 줄마다 복사. 위험 측점 = 맡은 띠 셀에 SI < 1(등급 4 이상)이 있는 측점. +- 22 행 서식을 줄마다 복사. 측점 등급 = 영향 구역(측점 앞뒤 ½ 간격 × 띠 폭) 안 SI < 1(등급 4 이상) 셀 비율 — 낮음 < 5 % · 보통 5~20 % · 높음 ≥ 20 %(문턱 = E07 설정). diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index f88f946c9..aa271e148 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1,444 +1 @@ -"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. - -가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · -3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · -갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. -""" - -import io -import json -import shutil -from pathlib import Path - -import pytest -from openpyxl import load_workbook - -from common_util.common_util_derived_store import derived_dir, write_derived -from common_util.common_util_print_pdf import PrintError -from common_util.common_util_spreadsheet import workbook_pdf -from common_util.common_util_workbook_xlsx import workbook_to_xlsx -from E02_StabGround import E02_StabGround_Model as ground_model -from E05_StabReport import E05_StabReport_Build as build -from E05_StabReport import E05_StabReport_Figure as figure -from E05_StabReport import E05_StabReport_Store as store - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -N = 30 # 측점 수 -NG_AT = {3, 10, 17, 24} # N.G 측점 -REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")] -# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔 -OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} -KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" -REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} - - -def _key(i: int) -> str: - return f"grid:{i}" - - -def _summary(i: int, bump: float = 0.0) -> dict: - """E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율.""" - ng = i in NG_AT and not bump - dry = 1.31 if ng else 1.7 + i / 100 + bump - role = "cut" if i % 2 else "fill" - side = "left" if role == "cut" else "right" - other = "right" if side == "left" else "left" - heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0} - fs = {"dry": dry, "wet": dry - 0.2} - cases = { - cond: { - side: {"fs": fs[cond], "required": REQUIRED[cond][role]}, - other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]}, - } - for cond in fs - } - return { - "key": _key(i), - "chainage_m": 20.0 * i, - "label": f"No.{i}", - "fp": f"st{i}", - "sides": [ - {"side": "left", "role": "cut", "height_m": heights["cut"]}, - {"side": "right", "role": "fill", "height_m": heights["fill"]}, - ], - "cases": cases, - "min": {c: min(v["fs"] for v in cases[c].values()) for c in cases}, - "verdict": { - c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng" - for c in cases - }, - "error": None, - } - - -def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: - value = { - "fingerprint": fp, - "computed_at": "2026-10-25T10:00:00", - "conditions": ["dry", "wet"], - "method": "bishop", - "stations": rows, - } - write_derived(root, KIND_SUMMARY, fp, value) - - -def _slip(F: float) -> dict: - pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)] - return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5} - - -def _station_doc(i: int) -> dict: - s = _summary(i) - cases = [] - for cond in ("dry", "wet"): - F = s["min"][cond] - for side, role in (("left", "cut"), ("right", "fill")): - cases.append( - { - "condition": cond, - "side": side, - "role": role, - "direction": -1 if side == "left" else 1, - "limits": [-10.0, 10.0], - "height_m": 6.0, - "min_F": {"bishop": F}, - "result": {"methods": {"bishop": {"min": _slip(F)}}}, - } - ) - return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases} - - -def _cross(chainage: float) -> dict: - samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)] - line = [ - {"offset_m": 8.0, "elevation_m": 103.2}, - {"offset_m": 2.0, "elevation_m": 100.0}, - {"offset_m": -2.0, "elevation_m": 100.0}, - {"offset_m": -6.0, "elevation_m": 97.6}, - ] - return { - "chainage_m": chainage, - "label": f"No.{int(chainage // 20)}", - "samples": samples, - "design": { - "design_line": line, - "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, - "ground_type": "ripping_rock", - }, - } - - -@pytest.fixture -def project(tmp_path, monkeypatch) -> Path: - root = tmp_path / "e_project" - _write_summary(root, "fp-1", [_summary(i) for i in range(N)]) - for key, _ in REPS: - i = int(key.split(":")[1]) - write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N) - (root / "E03_StabSection").mkdir() - (root / "E03_StabSection" / "representative.json").write_text( - json.dumps({"overrides": OFF}), encoding="utf-8" - ) - ground = ground_model.pick(ground_model.load_defaults()) - ground["stations"] = [ - {"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5} - for i in range(N) - ] - ground["zones"] = [ - {"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None} - ] - path = ground_model.ground_path(root) - path.parent.mkdir(parents=True) - 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( - json.dumps( - { - "fingerprint": "3d-1", - "settings": {}, - "summary": { - "start_m": 0.0, - "end_m": 200.0, - "f_min": 1.42, - "volume": 830.0, - "condition": "wet", - "reference_fs": 1.3, - "verdict": "ok", - }, - } - ), - encoding="utf-8", - ) - stations = [ - {"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i} - for i in range(N) - ] - db = { - "project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"}, - "route_points": [ - {"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i} - for i in range(N) - ], - } - monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db) - monkeypatch.setattr(build.source, "stations", lambda _root: stations) - monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch)) - return root - - -def _sheets(book: dict) -> dict: - return {s["이름"]: s for s in book["시트"]} - - -def _create(root: Path) -> dict: - try: - return store.create(root) - except PrintError as error: - pytest.skip(f"브라우저 없음 — {error}") - - -def test_처음_만듦_측점수_NG_그림(project): - meta = _create(project) - book, saved = store.load(project) - by = _sheets(book) - rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c] - rows = [a1 for a1 in rows if int(a1[1:]) >= 6] - assert len(rows) == N # 측점 수 = 줄 수 - j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6] - assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT) - assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT) - figs = sorted((project / "E05_StabReport" / "fig").glob("*.png")) - assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY - assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS] - assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84] - assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상" - assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31 - assert by["대표단면"]["칸"]["F24"]["값"] == 12.5 - assert by["표지"]["칸"]["C8"]["값"] == "검증 임도" - vals = book["계산값"]["s2"] - assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))} - # 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄 - s3 = by["입력조건"]["칸"] - assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층") - assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3 - s6 = by["3D요약"]["칸"] - assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정 - assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건) - assert (build.initial_dir(project) / build.REPORT).is_file() - assert saved["fingerprint"] == build.analysis_fingerprint(project) - - -def test_광역요약_E07_저장본(project): - """E07 저장본 없음 = 「광역 해석 전」 · 생김 = 지문 바뀜 → 갱신 뒤 등급 면적 · 위험 측점 줄.""" - _create(project) - book, meta = store.load(project) - assert _sheets(book)["광역요약"]["칸"]["B5"]["값"].startswith("광역 해석 전") - keys = ("stable", "moderate", "quasi", "lower", "upper", "defended") - area = { - "settings": { - "band_m": 100.0, - "cell_m": 2.0, - "gamma_t": 17.5, - "gamma_sat": 18.5, - "c": 5.0, - "c_hi": 10.0, - "phi": 27.0, - "phi_hi": 30.0, - "c_root": 0.0, - "soil_depth_m": 1.5, - "scenario": "sinmap", - "tr_lo": 2000.0, - "tr_hi": 3000.0, - }, - "computed_at": "2026-10-25T21:00:00", - "stations": [ - { - "chainage_m": 0.0, - "cells": 9, - "fs_min": 1.6, - "si_min": 1.6, - "worst_class": 1, - "risk_area_m2": 0.0, - "slope_max_deg": 12.0, - "label": "No.0", - }, - { - "chainage_m": 20.0, - "cells": 9, - "fs_min": 0.8, - "si_min": 0.0, - "worst_class": 6, - "risk_area_m2": 36.0, - "slope_max_deg": 41.5, - "label": "No.1", - }, - { - "chainage_m": 40.0, - "cells": 9, - "fs_min": 0.95, - "si_min": 0.7, - "worst_class": 4, - "risk_area_m2": 8.0, - "slope_max_deg": 33.0, - "label": "No.2", - }, - ], - "summary": {"areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0)))}, - } - path = project / "E07_StabArea" / "_derived" / "area.json" - path.parent.mkdir(parents=True) - path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") - assert build.analysis_fingerprint(project) != meta["fingerprint"] - store.refresh(project) - book, _ = store.load(project) - s7 = _sheets(book)["광역요약"]["칸"] - assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] - assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 - assert ( - s7["E22"]["값"] == "불안정" - and s7["F23"]["값"] == 8.0 - and s7["B7"]["값"].startswith("표준 — SINMAP") - ) - vals = book["계산값"]["s7"] - assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") - - -def test_갱신_사용자몫_불변_덮임(project): - _create(project) - book, _ = store.load(project) - by = _sheets(book) - by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} - by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15} - store.save(project, book) - out = store.refresh(project) - assert out["overwritten"] == [] # 프로그램 몫을 안 고침 - book, _ = store.load(project) - by = _sheets(book) - by["측점표"]["칸"]["A6"]["값"] = "고친 측점" - store.save(project, book) - rows = [_summary(i) for i in range(N)] - rows[3] = _summary(3, 0.5) - _write_summary(project, "fp-2", rows) - assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"] - try: - out = store.refresh(project) - except PrintError as error: - pytest.skip(f"브라우저 없음 — {error}") - assert out["overwritten"] == ["측점표"] - assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1 - book, meta = store.load(project) - by = _sheets(book) - assert ( - by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견" - ) - assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀 - assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감 - assert meta["fingerprint"] == build.analysis_fingerprint(project) - assert store.reset(project) - book, _ = store.load(project) - assert ( - "C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"] - ) - - -def test_3D_없음_2D_없음(project): - shutil.rmtree(project / "E04_Stab3D") - inputs = build.gather(project) - book = build.form() - build.build_program(book, inputs) - by = _sheets(book) - assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전" - assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전" - shutil.rmtree(derived_dir(project, KIND_SUMMARY)) - with pytest.raises(build.MissingInput): - build.gather(project) - - -def test_xlsx_되읽기_pdf_쪽수(project): - _create(project) - book, _ = store.load(project) - figs = store.figures_of(project, book) - assert len(figs) == len(REPS) + 1 - wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) - assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] - assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1 - assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84] - assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith( - "FFC7CE" - ) - book.pop("계산값", None) - try: - pdf = workbook_pdf(book, figs) - except PrintError as error: - pytest.skip(f"브라우저 없음 — {error}") - from pypdf import PdfReader - - texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] - stab = [i for i, t in enumerate(texts) if "산정 안전율" in t] - assert stab and all("산정 안전율" in texts[i] for i in stab) - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 - assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 - - -def test_단면_그림_SVG(): - svg = figure.section_svg( - title="No.3", - ground=[(-10.0, 96.0), (10.0, 104.0)], - surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)], - rock=[(-10.0, 94.5), (10.0, 102.5)], - slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}], - road=(-2.0, 2.0), - heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}], - wet=True, - ) - assert svg.startswith("= 4 - - -def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): - from fastapi import FastAPI - from fastapi.testclient import TestClient - - from common_util.common_util_auth import verify_session - from E05_StabReport import E05_StabReport_Router as router_mod - - async def fake_project(_pid): - return project, 3 - - monkeypatch.setattr(router_mod, "_project", fake_project) - app = FastAPI() - app.include_router(router_mod.router) - app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3} - client = TestClient(app) - url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report" - try: - got = client.get(url).json() - except PrintError as error: - pytest.skip(f"브라우저 없음 — {error}") - assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7 - book = got["book"] - _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} - assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 - saved = client.put(url, json={"rev": got["rev"], "book": book}).json() - assert saved["rev"] != got["rev"] - assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409 - out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json() - assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청" - fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")}) - assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG" - assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400 - x = client.post(f"{url}/xlsx", json={"book": out["book"]}) - assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지" - back = client.post(f"{url}/reset").json() - assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {}) - - -def test_서명_화면_왕복_같음(project): - """JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음.""" - book = build.form() - build.build_program(book, build.gather(project)) - s3 = _sheets(book)["입력조건"] - assert s3["칸"]["B22"]["값"] == 10.0 - trip = json.loads(json.dumps(s3).replace("10.0", "10")) - assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3) +"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. 가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. """ import io import json import shutil from pathlib import Path import pytest from openpyxl import load_workbook from common_util.common_util_derived_store import derived_dir, write_derived from common_util.common_util_print_pdf import PrintError from common_util.common_util_spreadsheet import workbook_pdf from common_util.common_util_workbook_xlsx import workbook_to_xlsx from E02_StabGround import E02_StabGround_Model as ground_model from E05_StabReport import E05_StabReport_Build as build from E05_StabReport import E05_StabReport_Figure as figure from E05_StabReport import E05_StabReport_Store as store pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") N = 30 # 측점 수 NG_AT = {3, 10, 17, 24} # N.G 측점 REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")] # 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔 OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} def _key(i: int) -> str: return f"grid:{i}" def _summary(i: int, bump: float = 0.0) -> dict: """E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율.""" ng = i in NG_AT and not bump dry = 1.31 if ng else 1.7 + i / 100 + bump role = "cut" if i % 2 else "fill" side = "left" if role == "cut" else "right" other = "right" if side == "left" else "left" heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0} fs = {"dry": dry, "wet": dry - 0.2} cases = { cond: { side: {"fs": fs[cond], "required": REQUIRED[cond][role]}, other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]}, } for cond in fs } return { "key": _key(i), "chainage_m": 20.0 * i, "label": f"No.{i}", "fp": f"st{i}", "sides": [ {"side": "left", "role": "cut", "height_m": heights["cut"]}, {"side": "right", "role": "fill", "height_m": heights["fill"]}, ], "cases": cases, "min": {c: min(v["fs"] for v in cases[c].values()) for c in cases}, "verdict": { c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng" for c in cases }, "error": None, } def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: value = { "fingerprint": fp, "computed_at": "2026-10-25T10:00:00", "conditions": ["dry", "wet"], "method": "bishop", "stations": rows, } write_derived(root, KIND_SUMMARY, fp, value) def _slip(F: float) -> dict: pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)] return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5} def _station_doc(i: int) -> dict: s = _summary(i) cases = [] for cond in ("dry", "wet"): F = s["min"][cond] for side, role in (("left", "cut"), ("right", "fill")): cases.append( { "condition": cond, "side": side, "role": role, "direction": -1 if side == "left" else 1, "limits": [-10.0, 10.0], "height_m": 6.0, "min_F": {"bishop": F}, "result": {"methods": {"bishop": {"min": _slip(F)}}}, } ) return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases} def _cross(chainage: float) -> dict: samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)] line = [ {"offset_m": 8.0, "elevation_m": 103.2}, {"offset_m": 2.0, "elevation_m": 100.0}, {"offset_m": -2.0, "elevation_m": 100.0}, {"offset_m": -6.0, "elevation_m": 97.6}, ] return { "chainage_m": chainage, "label": f"No.{int(chainage // 20)}", "samples": samples, "design": { "design_line": line, "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, "ground_type": "ripping_rock", }, } @pytest.fixture def project(tmp_path, monkeypatch) -> Path: root = tmp_path / "e_project" _write_summary(root, "fp-1", [_summary(i) for i in range(N)]) for key, _ in REPS: i = int(key.split(":")[1]) write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N) (root / "E03_StabSection").mkdir() (root / "E03_StabSection" / "representative.json").write_text( json.dumps({"overrides": OFF}), encoding="utf-8" ) ground = ground_model.pick(ground_model.load_defaults()) ground["stations"] = [ {"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5} for i in range(N) ] ground["zones"] = [ {"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None} ] path = ground_model.ground_path(root) path.parent.mkdir(parents=True) 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( json.dumps( { "fingerprint": "3d-1", "settings": {}, "summary": { "start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0, "condition": "wet", "reference_fs": 1.3, "verdict": "ok", }, } ), encoding="utf-8", ) stations = [ {"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i} for i in range(N) ] db = { "project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"}, "route_points": [ {"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i} for i in range(N) ], } monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db) monkeypatch.setattr(build.source, "stations", lambda _root: stations) monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch)) return root def _sheets(book: dict) -> dict: return {s["이름"]: s for s in book["시트"]} def _create(root: Path) -> dict: try: return store.create(root) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") def test_처음_만듦_측점수_NG_그림(project): meta = _create(project) book, saved = store.load(project) by = _sheets(book) rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c] rows = [a1 for a1 in rows if int(a1[1:]) >= 6] assert len(rows) == N # 측점 수 = 줄 수 j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6] assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT) assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT) figs = sorted((project / "E05_StabReport" / "fig").glob("*.png")) assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS] assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84] assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상" assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31 assert by["대표단면"]["칸"]["F24"]["값"] == 12.5 assert by["표지"]["칸"]["C8"]["값"] == "검증 임도" vals = book["계산값"]["s2"] assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))} # 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄 s3 = by["입력조건"]["칸"] assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층") assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3 s6 = by["3D요약"]["칸"] assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정 assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건) assert (build.initial_dir(project) / build.REPORT).is_file() assert saved["fingerprint"] == build.analysis_fingerprint(project) def test_광역요약_E07_저장본(project): """E07 저장본 없음 = 「광역 해석 전」 · 생김 = 지문 바뀜 → 갱신 뒤 등급 면적 · 위험 측점 줄.""" _create(project) book, meta = store.load(project) assert _sheets(book)["광역요약"]["칸"]["B5"]["값"].startswith("광역 해석 전") keys = ("stable", "moderate", "quasi", "lower", "upper", "defended") area = { "settings": { "band_m": 100.0, "cell_m": 2.0, "gamma_t": 17.5, "gamma_sat": 18.5, "c": 5.0, "c_hi": 10.0, "phi": 27.0, "phi_hi": 30.0, "c_root": 0.0, "soil_depth_m": 1.5, "scenario": "sinmap", "tr_lo": 2000.0, "tr_hi": 3000.0, "level_lo_pct": 5.0, "level_hi_pct": 20.0, }, "computed_at": "2026-10-25T21:00:00", "stations": [ { "chainage_m": 0.0, "cells": 9, "fs_min": 1.6, "si_min": 1.6, "worst_class": 1, "risk_area_m2": 0.0, "risk_ratio": 0.0, "level": "low", "slope_max_deg": 12.0, "label": "No.0", }, { "chainage_m": 20.0, "cells": 9, "fs_min": 0.8, "si_min": 0.0, "worst_class": 6, "risk_area_m2": 36.0, "risk_ratio": 1.0, "level": "high", "slope_max_deg": 41.5, "label": "No.1", }, { "chainage_m": 40.0, "cells": 9, "fs_min": 0.95, "si_min": 0.7, "worst_class": 4, "risk_area_m2": 8.0, "risk_ratio": 0.0556, "level": "mid", "slope_max_deg": 33.0, "label": "No.2", }, ], "summary": { "areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0))), "station_levels": {"low": 1, "mid": 1, "high": 1}, }, } path = project / "E07_StabArea" / "_derived" / "area.json" path.parent.mkdir(parents=True) path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") assert build.analysis_fingerprint(project) != meta["fingerprint"] store.refresh(project) book, _ = store.load(project) s7 = _sheets(book)["광역요약"]["칸"] assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 assert ( s7["E22"]["값"] == "높음 (100.0 %)" and s7["E23"]["값"] == "보통 (5.6 %)" and s7["F23"]["값"] == 8.0 and [s7[f"G{r}"]["값"] for r in (12, 13, 14)] == [1, 1, 1] and s7["F13"]["값"] == "5 ~ 20 %" and s7["B7"]["값"].startswith("표준 — SINMAP") ) vals = book["계산값"]["s7"] assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") def test_갱신_사용자몫_불변_덮임(project): _create(project) book, _ = store.load(project) by = _sheets(book) by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15} store.save(project, book) out = store.refresh(project) assert out["overwritten"] == [] # 프로그램 몫을 안 고침 book, _ = store.load(project) by = _sheets(book) by["측점표"]["칸"]["A6"]["값"] = "고친 측점" store.save(project, book) rows = [_summary(i) for i in range(N)] rows[3] = _summary(3, 0.5) _write_summary(project, "fp-2", rows) assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"] try: out = store.refresh(project) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") assert out["overwritten"] == ["측점표"] assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1 book, meta = store.load(project) by = _sheets(book) assert ( by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견" ) assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀 assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감 assert meta["fingerprint"] == build.analysis_fingerprint(project) assert store.reset(project) book, _ = store.load(project) assert ( "C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"] ) def test_3D_없음_2D_없음(project): shutil.rmtree(project / "E04_Stab3D") inputs = build.gather(project) book = build.form() build.build_program(book, inputs) by = _sheets(book) assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전" assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전" shutil.rmtree(derived_dir(project, KIND_SUMMARY)) with pytest.raises(build.MissingInput): build.gather(project) def test_xlsx_되읽기_pdf_쪽수(project): _create(project) book, _ = store.load(project) figs = store.figures_of(project, book) assert len(figs) == len(REPS) + 1 wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1 assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84] assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith( "FFC7CE" ) book.pop("계산값", None) try: pdf = workbook_pdf(book, figs) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") from pypdf import PdfReader texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] stab = [i for i, t in enumerate(texts) if "산정 안전율" in t] assert stab and all("산정 안전율" in texts[i] for i in stab) # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 def test_단면_그림_SVG(): svg = figure.section_svg( title="No.3", ground=[(-10.0, 96.0), (10.0, 104.0)], surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)], rock=[(-10.0, 94.5), (10.0, 102.5)], slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}], road=(-2.0, 2.0), heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}], wet=True, ) assert svg.startswith("= 4 def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from E05_StabReport import E05_StabReport_Router as router_mod async def fake_project(_pid): return project, 3 monkeypatch.setattr(router_mod, "_project", fake_project) app = FastAPI() app.include_router(router_mod.router) app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3} client = TestClient(app) url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report" try: got = client.get(url).json() except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7 book = got["book"] _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 saved = client.put(url, json={"rev": got["rev"], "book": book}).json() assert saved["rev"] != got["rev"] assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409 out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json() assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청" fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")}) assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG" assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400 x = client.post(f"{url}/xlsx", json={"book": out["book"]}) assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지" back = client.post(f"{url}/reset").json() assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {}) def test_서명_화면_왕복_같음(project): """JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음.""" book = build.form() build.build_program(book, build.gather(project)) s3 = _sheets(book)["입력조건"] assert s3["칸"]["B22"]["값"] == 10.0 trip = json.loads(json.dumps(s3).replace("10.0", "10")) assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3) \ No newline at end of file diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index f56853037..8aad39fee 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -1,225 +1 @@ -"""536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. - -무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · -D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · -가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. -""" - -import base64 -import json -import math - -import numpy as np -import pytest - -from E07_StabArea import E07_StabArea_Engine as engine -from E07_StabArea import E07_StabArea_Router as router_mod - -SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) -SAT = dict(scenario="saturated") - - -def _fs(beta_deg: float, m: float, **kw) -> float: - p = engine.AreaParams(**{**SOIL, **kw}) - return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0]) - - -def test_무한사면_해석해_건조_포화_점착(): - t = math.tan(math.radians(35)) / math.tan(math.radians(30)) - assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # 건조 c = 0 → tanφ / tanβ - assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # 포화 → γ' / γsat 배 - # 손계산 — c 5 · 뿌리 2 · z 1.5 · β 35° · m 0.5 - b, z = math.radians(35), 1.5 - w = 18 * z * 0.5 + 20 * z * 0.5 - n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2 - hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b)) - assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12) - assert math.isinf(_fs(0, 1.0)) # 평평 = 활동 없음 - - -def test_SINMAP_안정지수_닫힌꼴_표본(): - p = engine.AreaParams( - gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0, **SAT - ) - beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) - m = np.ones_like(beta) - si = engine.stability_index(beta, m, p) - a, b = engine._terms(beta, m, p) - rng = np.random.default_rng(1) - c = rng.uniform(0, 8, 400_000) - t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000) - for k in range(len(beta)): - lo = 0 * a[k] + math.tan(math.radians(25)) * b[k] - fs = c * a[k] + t * b[k] - if lo >= 1: - assert si[k] == pytest.approx(lo) # Fs_min ≥ 1 → SI = Fs_min - elif fs.max() < 1: - assert si[k] == 0.0 - else: - assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) - assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 - # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 - q = engine.AreaParams(**SOIL, **SAT) - fs = engine.infinite_slope_fs(beta, m, q) - si0 = engine.stability_index(beta, m, q) - assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) - - -def test_SINMAP_등급_경계(): - si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan]) - assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0] - - -def test_D8_집수면적_기운평면_V골짜기(): - cell = 2.0 - ny, nx = 5, 7 - xx = np.arange(nx)[None, :] * cell - plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # 동쪽으로 기움 → 행마다 동쪽으로 흐름 - a = engine.catchment_area(plane, cell) - assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell) - yy = np.arange(9)[:, None] * cell - valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell - a = engine.catchment_area(valley, cell) - assert a[4, -1] == pytest.approx(9 * nx * cell) # 전부가 골 출구로 - assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 - - -def test_습윤지수(): - beta = np.radians(np.array([30.0, 30.0, 0.0])) - a = np.array([100.0, 1e6, 50.0]) - w = engine.wetness(beta, a, 1 / 2000) - assert w[0] == pytest.approx(100 / (2000 * 0.5)) # R a / (T sinβ) - assert w[1] == 1.0 and w[2] == 1.0 # 넘침 · 평평 = 포화 - p = engine.AreaParams(**SOIL, tr_lo=2000, tr_hi=3000) - assert engine.x_range(p) == (1 / 3000, 1 / 2000) - assert engine.wet_m(beta, a, p).tolist() == w.tolist() # 기본 = 가장 젖은 쪽 - assert engine.wet_m(beta, a, engine.AreaParams(**SOIL, **SAT)).tolist() == [1.0] * 3 - - -SINMAP = dict(gamma_t=18.0, gamma_sat=18.0, c=2.0, c_hi=6.0, phi=30.0, phi_hi=40.0) - - -def _sinmap_fs(beta, a, c, tanphi, x, z=1.5, g=18.0): - """Pack 1998 식 — FS = [C + cosθ(1 − W r) tanφ] / sinθ · C = c / (h ρs g) · h = z cosθ.""" - w = min(x * a / math.sin(beta), 1.0) - h = z * math.cos(beta) - return (c / (h * g) + math.cos(beta) * (1 - w * 9.81 / g) * tanphi) / math.sin(beta) - - -def test_SI_하한_상한_해석해(): - """γt = γsat 이면 엔진 = SINMAP 원식 · 하한 = (c, φ 아래 · T/R 아래) · 상한 = (위 · T/R 위).""" - p = engine.AreaParams(**SINMAP, tr_lo=1000, tr_hi=3000) - t1, t2 = math.tan(math.radians(30)), math.tan(math.radians(40)) - beta = np.radians(np.array([15.0, 33.0, 38.0, 45.0, 70.0])) - area = np.array([20.0, 400.0, 300.0, 800.0, 5000.0]) - si = engine.stability_index(beta, area, p) - rng = np.random.default_rng(7) - n = 300_000 - c, t, x = rng.uniform(2, 6, n), rng.uniform(t1, t2, n), rng.uniform(1 / 3000, 1 / 1000, n) - seen = set() - for k, (b, ak) in enumerate(zip(beta, area)): - lo = _sinmap_fs(b, ak, 2.0, t1, 1 / 1000) - hi = _sinmap_fs(b, ak, 6.0, t2, 1 / 3000) - fs = engine.infinite_slope_fs( - beta[k : k + 1], engine.wet_m(beta[k : k + 1], area[k : k + 1], p), p - ) - assert fs[0] == pytest.approx(lo, rel=1e-12) # 결정 Fs = 하한 - if lo >= 1: - assert si[k] == pytest.approx(lo, rel=1e-12) - seen.add("lo") - elif hi < 1: - assert si[k] == 0.0 - seen.add("zero") - else: - w = np.minimum(x * ak / math.sin(b), 1) - h = 1.5 * math.cos(b) - mc = (c / (h * 18) + math.cos(b) * (1 - w * 9.81 / 18) * t) / math.sin(b) - assert si[k] == pytest.approx((mc > 1).mean(), abs=3e-3) # P(Fs > 1) · T/R 도 범위 - seen.add("prob") - assert seen == {"lo", "zero", "prob"} - - -def test_시나리오_둘_최악이_더_나쁨(): - beta = np.radians(np.linspace(5, 60, 56)) - area = np.full_like(beta, 50.0) - std = engine.stability_index(beta, area, engine.AreaParams(**SINMAP)) - worst = engine.stability_index(beta, area, engine.AreaParams(**SINMAP, **SAT)) - assert np.all(worst <= std + 1e-12) # 완전 포화 = 아래 경계 - assert (engine.classify(worst) >= engine.RISK_CLASS).sum() > ( - engine.classify(std) >= engine.RISK_CLASS - ).sum() - # 다 젖는 집수(x·a / sinβ ≥ 1) 면 두 시나리오가 같음 - big = np.full_like(beta, 1e7) - assert np.allclose( - engine.stability_index(beta, big, engine.AreaParams(**SINMAP)), worst, rtol=1e-12 - ) - - -def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: - return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"]) - - -@pytest.fixture() -def project(tmp_path, monkeypatch): - """가짜 E 복사본 — 서쪽이 낮은 30° 비탈(동서 200 m · 남북 100 m) · 노선 = 남북 중심선.""" - x = np.arange(0.0, 200.5, 0.5) - y = np.arange(0.0, 100.5, 0.5) - z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0) - valid = np.ones_like(z, bool) - valid[:, x > 180] = False # 동쪽 끝 = 지형 없음 - dem = tmp_path / "dtm.npz" - np.savez( - dem, - x=x.astype(np.float32), - y=y.astype(np.float32), - z=z.astype(np.float32), - valid_mask=valid, - ) - stations = [ - {"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)} - for i, c in enumerate(range(0, 101, 20)) - ] - route = [ - {"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)} - for i, c in enumerate((0, 100)) - ] - src = router_mod.source - monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem) - monkeypatch.setattr(src, "stations", lambda _r: stations) - monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route}) - return tmp_path - - -def test_길_띠_측점표_저장본(project): - base = router_mod.defaults(project) - assert base["scenario"] == "sinmap" and (base["tr_lo"], base["tr_hi"]) == (2000.0, 3000.0) - settings = {**base, "band_m": 30.0, "cell_m": 2.0, "scenario": "saturated", "c_root": 0.0} - assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 - steps = [] - summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) - doc = router_mod.load(project) - assert steps == sorted(steps) and doc["fingerprint"] == "fp" - g = doc["grid"] - cls = _decode(doc["cls"], "u1", g) - fs = _decode(doc["fs"], " 0] - assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3) - assert summary["fs_min"] == pytest.approx(hand, abs=5e-3) - # 띠 = 중심선 ± 30 m(격자 상자도 띠만큼) · 등급 면적 합 = 덮인 면적 - assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9 - assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"]) - assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0) - rows = doc["stations"] - assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100] - assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows)) - assert summary["risk_stations"] == [0, 20, 40, 60, 80, 100] - assert sum(r["cells"] for r in rows) == (cls > 0).sum() - # 지문 = 설정에 따라 바뀜 - assert router_mod.fingerprint("s", settings) != router_mod.fingerprint( - "s", {**settings, "c": 6} - ) - json.dumps(doc) # 그대로 JSON +"""536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. 무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. """ import base64 import json import math import numpy as np import pytest from E07_StabArea import E07_StabArea_Engine as engine from E07_StabArea import E07_StabArea_Router as router_mod SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) SAT = dict(scenario="saturated") def _fs(beta_deg: float, m: float, **kw) -> float: p = engine.AreaParams(**{**SOIL, **kw}) return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0]) def test_무한사면_해석해_건조_포화_점착(): t = math.tan(math.radians(35)) / math.tan(math.radians(30)) assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # 건조 c = 0 → tanφ / tanβ assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # 포화 → γ' / γsat 배 # 손계산 — c 5 · 뿌리 2 · z 1.5 · β 35° · m 0.5 b, z = math.radians(35), 1.5 w = 18 * z * 0.5 + 20 * z * 0.5 n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2 hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b)) assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12) assert math.isinf(_fs(0, 1.0)) # 평평 = 활동 없음 def test_SINMAP_안정지수_닫힌꼴_표본(): p = engine.AreaParams( gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0, **SAT ) beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) m = np.ones_like(beta) si = engine.stability_index(beta, m, p) a, b = engine._terms(beta, m, p) rng = np.random.default_rng(1) c = rng.uniform(0, 8, 400_000) t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000) for k in range(len(beta)): lo = 0 * a[k] + math.tan(math.radians(25)) * b[k] fs = c * a[k] + t * b[k] if lo >= 1: assert si[k] == pytest.approx(lo) # Fs_min ≥ 1 → SI = Fs_min elif fs.max() < 1: assert si[k] == 0.0 else: assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 q = engine.AreaParams(**SOIL, **SAT) fs = engine.infinite_slope_fs(beta, m, q) si0 = engine.stability_index(beta, m, q) assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) def test_SINMAP_등급_경계(): si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan]) assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0] def test_D8_집수면적_기운평면_V골짜기(): cell = 2.0 ny, nx = 5, 7 xx = np.arange(nx)[None, :] * cell plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # 동쪽으로 기움 → 행마다 동쪽으로 흐름 a = engine.catchment_area(plane, cell) assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell) yy = np.arange(9)[:, None] * cell valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell a = engine.catchment_area(valley, cell) assert a[4, -1] == pytest.approx(9 * nx * cell) # 전부가 골 출구로 assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 def test_습윤지수(): beta = np.radians(np.array([30.0, 30.0, 0.0])) a = np.array([100.0, 1e6, 50.0]) w = engine.wetness(beta, a, 1 / 2000) assert w[0] == pytest.approx(100 / (2000 * 0.5)) # R a / (T sinβ) assert w[1] == 1.0 and w[2] == 1.0 # 넘침 · 평평 = 포화 p = engine.AreaParams(**SOIL, tr_lo=2000, tr_hi=3000) assert engine.x_range(p) == (1 / 3000, 1 / 2000) assert engine.wet_m(beta, a, p).tolist() == w.tolist() # 기본 = 가장 젖은 쪽 assert engine.wet_m(beta, a, engine.AreaParams(**SOIL, **SAT)).tolist() == [1.0] * 3 SINMAP = dict(gamma_t=18.0, gamma_sat=18.0, c=2.0, c_hi=6.0, phi=30.0, phi_hi=40.0) def _sinmap_fs(beta, a, c, tanphi, x, z=1.5, g=18.0): """Pack 1998 식 — FS = [C + cosθ(1 − W r) tanφ] / sinθ · C = c / (h ρs g) · h = z cosθ.""" w = min(x * a / math.sin(beta), 1.0) h = z * math.cos(beta) return (c / (h * g) + math.cos(beta) * (1 - w * 9.81 / g) * tanphi) / math.sin(beta) def test_SI_하한_상한_해석해(): """γt = γsat 이면 엔진 = SINMAP 원식 · 하한 = (c, φ 아래 · T/R 아래) · 상한 = (위 · T/R 위).""" p = engine.AreaParams(**SINMAP, tr_lo=1000, tr_hi=3000) t1, t2 = math.tan(math.radians(30)), math.tan(math.radians(40)) beta = np.radians(np.array([15.0, 33.0, 38.0, 45.0, 70.0])) area = np.array([20.0, 400.0, 300.0, 800.0, 5000.0]) si = engine.stability_index(beta, area, p) rng = np.random.default_rng(7) n = 300_000 c, t, x = rng.uniform(2, 6, n), rng.uniform(t1, t2, n), rng.uniform(1 / 3000, 1 / 1000, n) seen = set() for k, (b, ak) in enumerate(zip(beta, area)): lo = _sinmap_fs(b, ak, 2.0, t1, 1 / 1000) hi = _sinmap_fs(b, ak, 6.0, t2, 1 / 3000) fs = engine.infinite_slope_fs( beta[k : k + 1], engine.wet_m(beta[k : k + 1], area[k : k + 1], p), p ) assert fs[0] == pytest.approx(lo, rel=1e-12) # 결정 Fs = 하한 if lo >= 1: assert si[k] == pytest.approx(lo, rel=1e-12) seen.add("lo") elif hi < 1: assert si[k] == 0.0 seen.add("zero") else: w = np.minimum(x * ak / math.sin(b), 1) h = 1.5 * math.cos(b) mc = (c / (h * 18) + math.cos(b) * (1 - w * 9.81 / 18) * t) / math.sin(b) assert si[k] == pytest.approx((mc > 1).mean(), abs=3e-3) # P(Fs > 1) · T/R 도 범위 seen.add("prob") assert seen == {"lo", "zero", "prob"} def test_시나리오_둘_최악이_더_나쁨(): beta = np.radians(np.linspace(5, 60, 56)) area = np.full_like(beta, 50.0) std = engine.stability_index(beta, area, engine.AreaParams(**SINMAP)) worst = engine.stability_index(beta, area, engine.AreaParams(**SINMAP, **SAT)) assert np.all(worst <= std + 1e-12) # 완전 포화 = 아래 경계 assert (engine.classify(worst) >= engine.RISK_CLASS).sum() > ( engine.classify(std) >= engine.RISK_CLASS ).sum() # 다 젖는 집수(x·a / sinβ ≥ 1) 면 두 시나리오가 같음 big = np.full_like(beta, 1e7) assert np.allclose( engine.stability_index(beta, big, engine.AreaParams(**SINMAP)), worst, rtol=1e-12 ) def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"]) @pytest.fixture() def project(tmp_path, monkeypatch): """가짜 E 복사본 — 서쪽이 낮은 30° 비탈(동서 200 m · 남북 100 m) · 노선 = 남북 중심선.""" x = np.arange(0.0, 200.5, 0.5) y = np.arange(0.0, 100.5, 0.5) z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0) valid = np.ones_like(z, bool) valid[:, x > 180] = False # 동쪽 끝 = 지형 없음 dem = tmp_path / "dtm.npz" np.savez( dem, x=x.astype(np.float32), y=y.astype(np.float32), z=z.astype(np.float32), valid_mask=valid, ) stations = [ {"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)} for i, c in enumerate(range(0, 101, 20)) ] route = [ {"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)} for i, c in enumerate((0, 100)) ] src = router_mod.source monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem) monkeypatch.setattr(src, "stations", lambda _r: stations) monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route}) return tmp_path def test_측점_등급_문턱_경계(): """위험 셀 비율 < 5 % 낮음 · 5 % 이상 20 % 미만 보통 · 20 % 이상 높음 (경계 포함은 윗등급).""" cases = [(0, 100, "low"), (4, 100, "low"), (5, 100, "mid"), (19, 100, "mid")] cases += [(20, 100, "high"), (100, 100, "high"), (1, 20, "mid"), (1, 21, "low"), (1, 5, "high")] for risk, cells, want in cases: assert engine.station_level(risk, cells) == want, (risk, cells) # 사용자가 바꾼 문턱 assert ( engine.station_level(9, 100, 10, 30) == "low" and engine.station_level(30, 100, 10, 30) == "high" ) def test_측점_영향_구역과_비율(): """측점 0 · 10 · 30 → 영향 구역 = 이웃과 중간 누가로 나눔 · 띠 안 셀만 · 자료 없음 제외.""" chain = np.arange(0.0, 40.0, 1.0)[None, :] # 1 × 40 셀 · 셀 i 누가 = i cls = np.ones(chain.shape, np.uint8) cls[0, [0, 1, 2, 3, 4]] = 4 # 측점 0 영역(0~5 · 정확히 중간 5 는 앞 측점) 6 셀 중 5 → 83 % cls[0, [6, 7]] = 5 # 측점 10 영역(6~20) 셀 14(19 는 띠 밖) · 위험 6 · 7 · 20 → 21 % cls[0, [20, 21]] = 6 # 20 은 측점 10 몫 · 측점 30 영역(21~39) 은 21 하나 cls[0, 39] = 0 # 자료 없음 → 셀 수에서 빠짐(18 셀) 1/18 → 5.6 % inside = np.ones(chain.shape, bool) inside[0, 19] = False # 띠 밖 → 빠짐 out = {k: np.ones(chain.shape) for k in ("fs", "si", "slope")} | {"cls": cls} rows = engine.station_table([30.0, 0.0, 10.0], chain, inside, out, 2.0) assert [r["chainage_m"] for r in rows] == [0.0, 10.0, 30.0] assert [r["cells"] for r in rows] == [6, 14, 18] assert [r["level"] for r in rows] == ["high", "high", "mid"] assert rows[0]["risk_ratio"] == round(5 / 6, 4) and rows[1]["risk_ratio"] == round(3 / 14, 4) assert rows[2]["risk_ratio"] == round(1 / 18, 4) and rows[2]["risk_area_m2"] == 4.0 # 문턱을 올리면 등급이 내려감 again = engine.station_table([0.0, 10.0, 30.0], chain, inside, out, 2.0, 15.0, 50.0) assert [r["level"] for r in again] == ["high", "mid", "low"] def test_측점_문턱_설정_검증(): base = router_mod.AreaRequest.model_fields assert (base["level_lo_pct"].default, base["level_hi_pct"].default) == (5.0, 20.0) ok = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=30.0, phi_hi=30.0) router_mod.AreaRequest(**ok, level_lo_pct=10, level_hi_pct=10) with pytest.raises(ValueError): router_mod.AreaRequest(**ok, level_lo_pct=30, level_hi_pct=10) def test_길_띠_측점표_저장본(project): base = router_mod.defaults(project) assert base["scenario"] == "sinmap" and (base["tr_lo"], base["tr_hi"]) == (2000.0, 3000.0) settings = {**base, "band_m": 30.0, "cell_m": 2.0, "scenario": "saturated", "c_root": 0.0} assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 steps = [] summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) doc = router_mod.load(project) assert steps == sorted(steps) and doc["fingerprint"] == "fp" g = doc["grid"] cls = _decode(doc["cls"], "u1", g) fs = _decode(doc["fs"], " 0] assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3) assert summary["fs_min"] == pytest.approx(hand, abs=5e-3) # 띠 = 중심선 ± 30 m(격자 상자도 띠만큼) · 등급 면적 합 = 덮인 면적 assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9 assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"]) assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0) rows = doc["stations"] assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100] assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows)) assert all(r["level"] == "high" and r["risk_ratio"] > 0.99 for r in rows) # 완전 포화 30° 비탈 assert summary["station_levels"] == {"low": 0, "mid": 0, "high": 6} assert sum(r["cells"] for r in rows) == (cls > 0).sum() # 지문 = 설정에 따라 바뀜 assert router_mod.fingerprint("s", settings) != router_mod.fingerprint( "s", {**settings, "c": 6} ) json.dumps(doc) # 그대로 JSON \ No newline at end of file diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts index cf7486a54..a405ebc12 100644 --- a/ui_template/ui_template_locale_e07.ts +++ b/ui_template/ui_template_locale_e07.ts @@ -17,7 +17,7 @@ export const ui_locales_e07 = { E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E07_Card_Soil: ["토층 물성", "Soil layer"], E07_Card_Rain: ["시나리오 · 포화", "Scenario · saturation"], - E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"], + E07_Card_Band: ["노선 띠 · 격자 · 측점 등급", "Route band · grid · station level"], E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"], E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"], E07_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"], @@ -40,6 +40,8 @@ export const ui_locales_e07 = { E07_TrHi: ["T/R 위 (m · 마른 쪽)", "T/R high (m · drier)"], E07_Band: ["띠 폭 ± (m)", "Band half-width (m)"], E07_Cell: ["격자 (m)", "Cell (m)"], + E07_LevelLo: ["측점 보통 문턱 (SI<1 비율 %)", "Station mid threshold (SI<1 share %)"], + E07_LevelHi: ["측점 높음 문턱 (SI<1 비율 %)", "Station high threshold (SI<1 share %)"], E07_Defaults: ["기본값", "Defaults"], E07_Analyze: ["해석", "Analyze"], E07_Reanalyze: ["다시 해석", "Re-analyze"], @@ -74,20 +76,23 @@ export const ui_locales_e07 = { ], E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"], E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"], - E07_Sum_RiskStations: ["위험 측점", "Hazard stations"], + E07_Sum_Levels: ["측점 등급 (낮음 · 보통 · 높음)", "Station levels (low · mid · high)"], E07_Sum_Covered: ["지형 있는 띠 / 띠", "Band with terrain / band"], E07_Sum_Time: ["해석", "Computed"], E07_Areas_Title: ["등급별 면적", "Area by class"], E07_Stations_Title: ["측점별 위험 (누르면 지도로)", "Hazard by station (click to locate)"], + E07_Level_low: ["낮음", "Low"], + E07_Level_mid: ["보통", "Medium"], + E07_Level_high: ["높음", "High"], E07_Col_Class: ["등급", "Class"], E07_Col_Area: ["면적 (m²)", "Area (m²)"], E07_Col_Station: ["측점", "Station"], E07_Col_FsMin: ["최소 Fs", "Min Fs"], - E07_Col_SiMin: ["최소 SI", "Min SI"], - E07_Col_Worst: ["나쁜 등급", "Worst class"], + E07_Col_Ratio: ["SI<1 비율 (%)", "SI<1 share (%)"], + E07_Col_Level: ["측점 등급", "Station level"], E07_Col_RiskArea: ["위험 m²", "Hazard m²"], E07_Defaults_Source: [ - "기본값 = 근거 후보 · 바꿀 수 있음 — 물성 = E02 붕적토 · T/R 2,000~3,000 m = SINMAP 설명서 기본 보정 · 뿌리 점착 1 kPa = 국내 연구(산림 0~5 kPa · 나지 · 절성토면 0).", - "Defaults are evidence-based candidates and editable — soil = E02 colluvium · T/R 2,000–3,000 m = SINMAP manual default · root cohesion 1 kPa = Korean studies (forest 0–5 kPa · bare / cut slopes 0).", + "기본값 = 근거 후보 · 바꿀 수 있음 — 물성 = E02 붕적토 · T/R 2,000~3,000 m = SINMAP 설명서 기본 보정 · 뿌리 점착 1 kPa = 국내 연구(산림 0~5 kPa · 나지 · 절성토면 0) — 사용자 확정 전 · 측점 등급 문턱 5 % · 20 % = 영향 구역(측점 앞뒤 ½ 간격 × 띠 폭) 안 SI<1 셀 비율 — 사용자 확정 전.", + "Defaults are evidence-based candidates and editable — soil = E02 colluvium · T/R 2,000–3,000 m = SINMAP manual default · root cohesion 1 kPa = Korean studies (forest 0–5 kPa · bare / cut slopes 0) — not yet confirmed by the user · station level thresholds 5 % · 20 % = share of SI<1 cells in the station zone (half-spacing either side × band width) — not yet confirmed by the user.", ], } as const satisfies Record; From 554b4c6bfcfaf648c87b032668e08b0f1d120758 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:29:50 +0900 Subject: [PATCH 28/34] =?UTF-8?q?test(536-2):=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EB=93=B1=EA=B8=89=20=EC=8B=9C=ED=97=98=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EA=B9=A8=EC=A7=90=20=EB=90=98=EC=82=B4=EB=A6=BC=20?= =?UTF-8?q?=E2=80=94=20=EC=A7=81=EC=A0=84=20=EC=BB=A4=EB=B0=8B=EC=9D=98=20?= =?UTF-8?q?=EB=91=90=20=EC=8B=9C=ED=97=98=20=ED=8C=8C=EC=9D=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=97=86=EC=9D=B4=20=EC=98=AC?= =?UTF-8?q?=EB=9D=BC=EA=B0=90=20=C2=B7=20=EB=82=B4=EC=9A=A9=EC=9D=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=93=B1=EA=B8=89=20=EB=AC=B8=ED=84=B1=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=C2=B7=20=EC=98=81=ED=96=A5=20=EA=B5=AC?= =?UTF-8?q?=EC=97=AD=20=C2=B7=20=EC=84=A4=EC=A0=95=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=C2=B7=20E05=20=EC=B8=A1=EC=A0=90=20=EB=93=B1=EA=B8=89=20?= =?UTF-8?q?=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_534_3_e05_report.py | 459 +++++++++++++++++++++- resources/tester/test_536_2_e07_area.py | 269 ++++++++++++- 2 files changed, 726 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index aa271e148..5e3b36f1d 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1 +1,458 @@ -"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. 가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. """ import io import json import shutil from pathlib import Path import pytest from openpyxl import load_workbook from common_util.common_util_derived_store import derived_dir, write_derived from common_util.common_util_print_pdf import PrintError from common_util.common_util_spreadsheet import workbook_pdf from common_util.common_util_workbook_xlsx import workbook_to_xlsx from E02_StabGround import E02_StabGround_Model as ground_model from E05_StabReport import E05_StabReport_Build as build from E05_StabReport import E05_StabReport_Figure as figure from E05_StabReport import E05_StabReport_Store as store pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") N = 30 # 측점 수 NG_AT = {3, 10, 17, 24} # N.G 측점 REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")] # 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔 OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} def _key(i: int) -> str: return f"grid:{i}" def _summary(i: int, bump: float = 0.0) -> dict: """E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율.""" ng = i in NG_AT and not bump dry = 1.31 if ng else 1.7 + i / 100 + bump role = "cut" if i % 2 else "fill" side = "left" if role == "cut" else "right" other = "right" if side == "left" else "left" heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0} fs = {"dry": dry, "wet": dry - 0.2} cases = { cond: { side: {"fs": fs[cond], "required": REQUIRED[cond][role]}, other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]}, } for cond in fs } return { "key": _key(i), "chainage_m": 20.0 * i, "label": f"No.{i}", "fp": f"st{i}", "sides": [ {"side": "left", "role": "cut", "height_m": heights["cut"]}, {"side": "right", "role": "fill", "height_m": heights["fill"]}, ], "cases": cases, "min": {c: min(v["fs"] for v in cases[c].values()) for c in cases}, "verdict": { c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng" for c in cases }, "error": None, } def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: value = { "fingerprint": fp, "computed_at": "2026-10-25T10:00:00", "conditions": ["dry", "wet"], "method": "bishop", "stations": rows, } write_derived(root, KIND_SUMMARY, fp, value) def _slip(F: float) -> dict: pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)] return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5} def _station_doc(i: int) -> dict: s = _summary(i) cases = [] for cond in ("dry", "wet"): F = s["min"][cond] for side, role in (("left", "cut"), ("right", "fill")): cases.append( { "condition": cond, "side": side, "role": role, "direction": -1 if side == "left" else 1, "limits": [-10.0, 10.0], "height_m": 6.0, "min_F": {"bishop": F}, "result": {"methods": {"bishop": {"min": _slip(F)}}}, } ) return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases} def _cross(chainage: float) -> dict: samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)] line = [ {"offset_m": 8.0, "elevation_m": 103.2}, {"offset_m": 2.0, "elevation_m": 100.0}, {"offset_m": -2.0, "elevation_m": 100.0}, {"offset_m": -6.0, "elevation_m": 97.6}, ] return { "chainage_m": chainage, "label": f"No.{int(chainage // 20)}", "samples": samples, "design": { "design_line": line, "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, "ground_type": "ripping_rock", }, } @pytest.fixture def project(tmp_path, monkeypatch) -> Path: root = tmp_path / "e_project" _write_summary(root, "fp-1", [_summary(i) for i in range(N)]) for key, _ in REPS: i = int(key.split(":")[1]) write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N) (root / "E03_StabSection").mkdir() (root / "E03_StabSection" / "representative.json").write_text( json.dumps({"overrides": OFF}), encoding="utf-8" ) ground = ground_model.pick(ground_model.load_defaults()) ground["stations"] = [ {"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5} for i in range(N) ] ground["zones"] = [ {"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None} ] path = ground_model.ground_path(root) path.parent.mkdir(parents=True) 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( json.dumps( { "fingerprint": "3d-1", "settings": {}, "summary": { "start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0, "condition": "wet", "reference_fs": 1.3, "verdict": "ok", }, } ), encoding="utf-8", ) stations = [ {"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i} for i in range(N) ] db = { "project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"}, "route_points": [ {"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i} for i in range(N) ], } monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db) monkeypatch.setattr(build.source, "stations", lambda _root: stations) monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch)) return root def _sheets(book: dict) -> dict: return {s["이름"]: s for s in book["시트"]} def _create(root: Path) -> dict: try: return store.create(root) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") def test_처음_만듦_측점수_NG_그림(project): meta = _create(project) book, saved = store.load(project) by = _sheets(book) rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c] rows = [a1 for a1 in rows if int(a1[1:]) >= 6] assert len(rows) == N # 측점 수 = 줄 수 j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6] assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT) assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT) figs = sorted((project / "E05_StabReport" / "fig").glob("*.png")) assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS] assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84] assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상" assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31 assert by["대표단면"]["칸"]["F24"]["값"] == 12.5 assert by["표지"]["칸"]["C8"]["값"] == "검증 임도" vals = book["계산값"]["s2"] assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))} # 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄 s3 = by["입력조건"]["칸"] assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층") assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3 s6 = by["3D요약"]["칸"] assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정 assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건) assert (build.initial_dir(project) / build.REPORT).is_file() assert saved["fingerprint"] == build.analysis_fingerprint(project) def test_광역요약_E07_저장본(project): """E07 저장본 없음 = 「광역 해석 전」 · 생김 = 지문 바뀜 → 갱신 뒤 등급 면적 · 위험 측점 줄.""" _create(project) book, meta = store.load(project) assert _sheets(book)["광역요약"]["칸"]["B5"]["값"].startswith("광역 해석 전") keys = ("stable", "moderate", "quasi", "lower", "upper", "defended") area = { "settings": { "band_m": 100.0, "cell_m": 2.0, "gamma_t": 17.5, "gamma_sat": 18.5, "c": 5.0, "c_hi": 10.0, "phi": 27.0, "phi_hi": 30.0, "c_root": 0.0, "soil_depth_m": 1.5, "scenario": "sinmap", "tr_lo": 2000.0, "tr_hi": 3000.0, "level_lo_pct": 5.0, "level_hi_pct": 20.0, }, "computed_at": "2026-10-25T21:00:00", "stations": [ { "chainage_m": 0.0, "cells": 9, "fs_min": 1.6, "si_min": 1.6, "worst_class": 1, "risk_area_m2": 0.0, "risk_ratio": 0.0, "level": "low", "slope_max_deg": 12.0, "label": "No.0", }, { "chainage_m": 20.0, "cells": 9, "fs_min": 0.8, "si_min": 0.0, "worst_class": 6, "risk_area_m2": 36.0, "risk_ratio": 1.0, "level": "high", "slope_max_deg": 41.5, "label": "No.1", }, { "chainage_m": 40.0, "cells": 9, "fs_min": 0.95, "si_min": 0.7, "worst_class": 4, "risk_area_m2": 8.0, "risk_ratio": 0.0556, "level": "mid", "slope_max_deg": 33.0, "label": "No.2", }, ], "summary": { "areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0))), "station_levels": {"low": 1, "mid": 1, "high": 1}, }, } path = project / "E07_StabArea" / "_derived" / "area.json" path.parent.mkdir(parents=True) path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") assert build.analysis_fingerprint(project) != meta["fingerprint"] store.refresh(project) book, _ = store.load(project) s7 = _sheets(book)["광역요약"]["칸"] assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 assert ( s7["E22"]["값"] == "높음 (100.0 %)" and s7["E23"]["값"] == "보통 (5.6 %)" and s7["F23"]["값"] == 8.0 and [s7[f"G{r}"]["값"] for r in (12, 13, 14)] == [1, 1, 1] and s7["F13"]["값"] == "5 ~ 20 %" and s7["B7"]["값"].startswith("표준 — SINMAP") ) vals = book["계산값"]["s7"] assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") def test_갱신_사용자몫_불변_덮임(project): _create(project) book, _ = store.load(project) by = _sheets(book) by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15} store.save(project, book) out = store.refresh(project) assert out["overwritten"] == [] # 프로그램 몫을 안 고침 book, _ = store.load(project) by = _sheets(book) by["측점표"]["칸"]["A6"]["값"] = "고친 측점" store.save(project, book) rows = [_summary(i) for i in range(N)] rows[3] = _summary(3, 0.5) _write_summary(project, "fp-2", rows) assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"] try: out = store.refresh(project) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") assert out["overwritten"] == ["측점표"] assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1 book, meta = store.load(project) by = _sheets(book) assert ( by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견" ) assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀 assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감 assert meta["fingerprint"] == build.analysis_fingerprint(project) assert store.reset(project) book, _ = store.load(project) assert ( "C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"] ) def test_3D_없음_2D_없음(project): shutil.rmtree(project / "E04_Stab3D") inputs = build.gather(project) book = build.form() build.build_program(book, inputs) by = _sheets(book) assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전" assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전" shutil.rmtree(derived_dir(project, KIND_SUMMARY)) with pytest.raises(build.MissingInput): build.gather(project) def test_xlsx_되읽기_pdf_쪽수(project): _create(project) book, _ = store.load(project) figs = store.figures_of(project, book) assert len(figs) == len(REPS) + 1 wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1 assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84] assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith( "FFC7CE" ) book.pop("계산값", None) try: pdf = workbook_pdf(book, figs) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") from pypdf import PdfReader texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] stab = [i for i, t in enumerate(texts) if "산정 안전율" in t] assert stab and all("산정 안전율" in texts[i] for i in stab) # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 def test_단면_그림_SVG(): svg = figure.section_svg( title="No.3", ground=[(-10.0, 96.0), (10.0, 104.0)], surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)], rock=[(-10.0, 94.5), (10.0, 102.5)], slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}], road=(-2.0, 2.0), heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}], wet=True, ) assert svg.startswith("= 4 def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from E05_StabReport import E05_StabReport_Router as router_mod async def fake_project(_pid): return project, 3 monkeypatch.setattr(router_mod, "_project", fake_project) app = FastAPI() app.include_router(router_mod.router) app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3} client = TestClient(app) url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report" try: got = client.get(url).json() except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7 book = got["book"] _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 saved = client.put(url, json={"rev": got["rev"], "book": book}).json() assert saved["rev"] != got["rev"] assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409 out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json() assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청" fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")}) assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG" assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400 x = client.post(f"{url}/xlsx", json={"book": out["book"]}) assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지" back = client.post(f"{url}/reset").json() assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {}) def test_서명_화면_왕복_같음(project): """JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음.""" book = build.form() build.build_program(book, build.gather(project)) s3 = _sheets(book)["입력조건"] assert s3["칸"]["B22"]["값"] == 10.0 trip = json.loads(json.dumps(s3).replace("10.0", "10")) assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3) \ No newline at end of file +"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. + +가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · +3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · +갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. +""" + +import io +import json +import shutil +from pathlib import Path + +import pytest +from openpyxl import load_workbook + +from common_util.common_util_derived_store import derived_dir, write_derived +from common_util.common_util_print_pdf import PrintError +from common_util.common_util_spreadsheet import workbook_pdf +from common_util.common_util_workbook_xlsx import workbook_to_xlsx +from E02_StabGround import E02_StabGround_Model as ground_model +from E05_StabReport import E05_StabReport_Build as build +from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport import E05_StabReport_Store as store + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +N = 30 # 측점 수 +NG_AT = {3, 10, 17, 24} # N.G 측점 +REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")] +# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔 +OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} +KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" +REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} + + +def _key(i: int) -> str: + return f"grid:{i}" + + +def _summary(i: int, bump: float = 0.0) -> dict: + """E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율.""" + ng = i in NG_AT and not bump + dry = 1.31 if ng else 1.7 + i / 100 + bump + role = "cut" if i % 2 else "fill" + side = "left" if role == "cut" else "right" + other = "right" if side == "left" else "left" + heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0} + fs = {"dry": dry, "wet": dry - 0.2} + cases = { + cond: { + side: {"fs": fs[cond], "required": REQUIRED[cond][role]}, + other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]}, + } + for cond in fs + } + return { + "key": _key(i), + "chainage_m": 20.0 * i, + "label": f"No.{i}", + "fp": f"st{i}", + "sides": [ + {"side": "left", "role": "cut", "height_m": heights["cut"]}, + {"side": "right", "role": "fill", "height_m": heights["fill"]}, + ], + "cases": cases, + "min": {c: min(v["fs"] for v in cases[c].values()) for c in cases}, + "verdict": { + c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng" + for c in cases + }, + "error": None, + } + + +def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: + value = { + "fingerprint": fp, + "computed_at": "2026-10-25T10:00:00", + "conditions": ["dry", "wet"], + "method": "bishop", + "stations": rows, + } + write_derived(root, KIND_SUMMARY, fp, value) + + +def _slip(F: float) -> dict: + pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)] + return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5} + + +def _station_doc(i: int) -> dict: + s = _summary(i) + cases = [] + for cond in ("dry", "wet"): + F = s["min"][cond] + for side, role in (("left", "cut"), ("right", "fill")): + cases.append( + { + "condition": cond, + "side": side, + "role": role, + "direction": -1 if side == "left" else 1, + "limits": [-10.0, 10.0], + "height_m": 6.0, + "min_F": {"bishop": F}, + "result": {"methods": {"bishop": {"min": _slip(F)}}}, + } + ) + return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases} + + +def _cross(chainage: float) -> dict: + samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)] + line = [ + {"offset_m": 8.0, "elevation_m": 103.2}, + {"offset_m": 2.0, "elevation_m": 100.0}, + {"offset_m": -2.0, "elevation_m": 100.0}, + {"offset_m": -6.0, "elevation_m": 97.6}, + ] + return { + "chainage_m": chainage, + "label": f"No.{int(chainage // 20)}", + "samples": samples, + "design": { + "design_line": line, + "road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}}, + "ground_type": "ripping_rock", + }, + } + + +@pytest.fixture +def project(tmp_path, monkeypatch) -> Path: + root = tmp_path / "e_project" + _write_summary(root, "fp-1", [_summary(i) for i in range(N)]) + for key, _ in REPS: + i = int(key.split(":")[1]) + write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N) + (root / "E03_StabSection").mkdir() + (root / "E03_StabSection" / "representative.json").write_text( + json.dumps({"overrides": OFF}), encoding="utf-8" + ) + ground = ground_model.pick(ground_model.load_defaults()) + ground["stations"] = [ + {"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5} + for i in range(N) + ] + ground["zones"] = [ + {"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None} + ] + path = ground_model.ground_path(root) + path.parent.mkdir(parents=True) + 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( + json.dumps( + { + "fingerprint": "3d-1", + "settings": {}, + "summary": { + "start_m": 0.0, + "end_m": 200.0, + "f_min": 1.42, + "volume": 830.0, + "condition": "wet", + "reference_fs": 1.3, + "verdict": "ok", + }, + } + ), + encoding="utf-8", + ) + stations = [ + {"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i} + for i in range(N) + ] + db = { + "project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"}, + "route_points": [ + {"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i} + for i in range(N) + ], + } + monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db) + monkeypatch.setattr(build.source, "stations", lambda _root: stations) + monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch)) + return root + + +def _sheets(book: dict) -> dict: + return {s["이름"]: s for s in book["시트"]} + + +def _create(root: Path) -> dict: + try: + return store.create(root) + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + + +def test_처음_만듦_측점수_NG_그림(project): + meta = _create(project) + book, saved = store.load(project) + by = _sheets(book) + rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c] + rows = [a1 for a1 in rows if int(a1[1:]) >= 6] + assert len(rows) == N # 측점 수 = 줄 수 + j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6] + assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT) + assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT) + figs = sorted((project / "E05_StabReport" / "fig").glob("*.png")) + assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY + assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS] + assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84] + assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상" + assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31 + assert by["대표단면"]["칸"]["F24"]["값"] == 12.5 + assert by["표지"]["칸"]["C8"]["값"] == "검증 임도" + vals = book["계산값"]["s2"] + assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))} + # 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄 + s3 = by["입력조건"]["칸"] + assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층") + assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3 + s6 = by["3D요약"]["칸"] + assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정 + assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건) + assert (build.initial_dir(project) / build.REPORT).is_file() + assert saved["fingerprint"] == build.analysis_fingerprint(project) + + +def test_광역요약_E07_저장본(project): + """E07 저장본 없음 = 「광역 해석 전」 · 생김 = 지문 바뀜 → 갱신 뒤 등급 면적 · 위험 측점 줄.""" + _create(project) + book, meta = store.load(project) + assert _sheets(book)["광역요약"]["칸"]["B5"]["값"].startswith("광역 해석 전") + keys = ("stable", "moderate", "quasi", "lower", "upper", "defended") + area = { + "settings": { + "band_m": 100.0, + "cell_m": 2.0, + "gamma_t": 17.5, + "gamma_sat": 18.5, + "c": 5.0, + "c_hi": 10.0, + "phi": 27.0, + "phi_hi": 30.0, + "c_root": 0.0, + "soil_depth_m": 1.5, + "scenario": "sinmap", + "tr_lo": 2000.0, + "tr_hi": 3000.0, + "level_lo_pct": 5.0, + "level_hi_pct": 20.0, + }, + "computed_at": "2026-10-25T21:00:00", + "stations": [ + { + "chainage_m": 0.0, + "cells": 9, + "fs_min": 1.6, + "si_min": 1.6, + "worst_class": 1, + "risk_area_m2": 0.0, + "risk_ratio": 0.0, + "level": "low", + "slope_max_deg": 12.0, + "label": "No.0", + }, + { + "chainage_m": 20.0, + "cells": 9, + "fs_min": 0.8, + "si_min": 0.0, + "worst_class": 6, + "risk_area_m2": 36.0, + "risk_ratio": 1.0, + "level": "high", + "slope_max_deg": 41.5, + "label": "No.1", + }, + { + "chainage_m": 40.0, + "cells": 9, + "fs_min": 0.95, + "si_min": 0.7, + "worst_class": 4, + "risk_area_m2": 8.0, + "risk_ratio": 0.0556, + "level": "mid", + "slope_max_deg": 33.0, + "label": "No.2", + }, + ], + "summary": { + "areas_m2": dict(zip(keys, (100.0, 20.0, 12.0, 8.0, 0.0, 36.0))), + "station_levels": {"low": 1, "mid": 1, "high": 1}, + }, + } + path = project / "E07_StabArea" / "_derived" / "area.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps(area, ensure_ascii=False), encoding="utf-8") + assert build.analysis_fingerprint(project) != meta["fingerprint"] + store.refresh(project) + book, _ = store.load(project) + s7 = _sheets(book)["광역요약"]["칸"] + assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] + assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 + assert ( + s7["E22"]["값"] == "높음 (100.0 %)" + and s7["E23"]["값"] == "보통 (5.6 %)" + and s7["F23"]["값"] == 8.0 + and [s7[f"G{r}"]["값"] for r in (12, 13, 14)] == [1, 1, 1] + and s7["F13"]["값"] == "5 ~ 20 %" + and s7["B7"]["값"].startswith("표준 — SINMAP") + ) + vals = book["계산값"]["s7"] + assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") + + +def test_갱신_사용자몫_불변_덮임(project): + _create(project) + book, _ = store.load(project) + by = _sheets(book) + by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} + by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15} + store.save(project, book) + out = store.refresh(project) + assert out["overwritten"] == [] # 프로그램 몫을 안 고침 + book, _ = store.load(project) + by = _sheets(book) + by["측점표"]["칸"]["A6"]["값"] = "고친 측점" + store.save(project, book) + rows = [_summary(i) for i in range(N)] + rows[3] = _summary(3, 0.5) + _write_summary(project, "fp-2", rows) + assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"] + try: + out = store.refresh(project) + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + assert out["overwritten"] == ["측점표"] + assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1 + book, meta = store.load(project) + by = _sheets(book) + assert ( + by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견" + ) + assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀 + assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감 + assert meta["fingerprint"] == build.analysis_fingerprint(project) + assert store.reset(project) + book, _ = store.load(project) + assert ( + "C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"] + ) + + +def test_3D_없음_2D_없음(project): + shutil.rmtree(project / "E04_Stab3D") + inputs = build.gather(project) + book = build.form() + build.build_program(book, inputs) + by = _sheets(book) + assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전" + assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전" + shutil.rmtree(derived_dir(project, KIND_SUMMARY)) + with pytest.raises(build.MissingInput): + build.gather(project) + + +def test_xlsx_되읽기_pdf_쪽수(project): + _create(project) + book, _ = store.load(project) + figs = store.figures_of(project, book) + assert len(figs) == len(REPS) + 1 + wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) + assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] + assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1 + assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84] + assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith( + "FFC7CE" + ) + book.pop("계산값", None) + try: + pdf = workbook_pdf(book, figs) + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + from pypdf import PdfReader + + texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] + stab = [i for i, t in enumerate(texts) if "산정 안전율" in t] + assert stab and all("산정 안전율" in texts[i] for i in stab) + # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 + + +def test_단면_그림_SVG(): + svg = figure.section_svg( + title="No.3", + ground=[(-10.0, 96.0), (10.0, 104.0)], + surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)], + rock=[(-10.0, 94.5), (10.0, 102.5)], + slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}], + road=(-2.0, 2.0), + heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}], + wet=True, + ) + assert svg.startswith("= 4 + + +def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from E05_StabReport import E05_StabReport_Router as router_mod + + async def fake_project(_pid): + return project, 3 + + monkeypatch.setattr(router_mod, "_project", fake_project) + app = FastAPI() + app.include_router(router_mod.router) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3} + client = TestClient(app) + url = "/api/projects/3d3776a1-2973-4385-8dcc-fdc6e49a900e/stab/report" + try: + got = client.get(url).json() + except PrintError as error: + pytest.skip(f"브라우저 없음 — {error}") + assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7 + book = got["book"] + _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} + assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 + saved = client.put(url, json={"rev": got["rev"], "book": book}).json() + assert saved["rev"] != got["rev"] + assert client.post(f"{url}/refresh", json={"rev": got["rev"]}).status_code == 409 + out = client.post(f"{url}/refresh", json={"rev": saved["rev"]}).json() + assert out["overwritten"] == [] and _sheets(out["book"])["표지"]["칸"]["C10"]["값"] == "산림청" + fig = client.get(f"{url}/fig", params={"key": build.fig_key("grid:3")}) + assert fig.status_code == 200 and fig.content[:4] == b"\x89PNG" + assert client.get(f"{url}/fig", params={"key": "../x"}).status_code == 400 + x = client.post(f"{url}/xlsx", json={"book": out["book"]}) + assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지" + back = client.post(f"{url}/reset").json() + assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {}) + + +def test_서명_화면_왕복_같음(project): + """JS 를 거친 책(10.0 → 10)도 같은 서명 — 안 고친 시트를 덮임으로 알리지 않음.""" + book = build.form() + build.build_program(book, build.gather(project)) + s3 = _sheets(book)["입력조건"] + assert s3["칸"]["B22"]["값"] == 10.0 + trip = json.loads(json.dumps(s3).replace("10.0", "10")) + assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3) diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index 8aad39fee..6bfe08965 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -1 +1,268 @@ -"""536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. 무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. """ import base64 import json import math import numpy as np import pytest from E07_StabArea import E07_StabArea_Engine as engine from E07_StabArea import E07_StabArea_Router as router_mod SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) SAT = dict(scenario="saturated") def _fs(beta_deg: float, m: float, **kw) -> float: p = engine.AreaParams(**{**SOIL, **kw}) return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0]) def test_무한사면_해석해_건조_포화_점착(): t = math.tan(math.radians(35)) / math.tan(math.radians(30)) assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # 건조 c = 0 → tanφ / tanβ assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # 포화 → γ' / γsat 배 # 손계산 — c 5 · 뿌리 2 · z 1.5 · β 35° · m 0.5 b, z = math.radians(35), 1.5 w = 18 * z * 0.5 + 20 * z * 0.5 n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2 hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b)) assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12) assert math.isinf(_fs(0, 1.0)) # 평평 = 활동 없음 def test_SINMAP_안정지수_닫힌꼴_표본(): p = engine.AreaParams( gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0, **SAT ) beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) m = np.ones_like(beta) si = engine.stability_index(beta, m, p) a, b = engine._terms(beta, m, p) rng = np.random.default_rng(1) c = rng.uniform(0, 8, 400_000) t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000) for k in range(len(beta)): lo = 0 * a[k] + math.tan(math.radians(25)) * b[k] fs = c * a[k] + t * b[k] if lo >= 1: assert si[k] == pytest.approx(lo) # Fs_min ≥ 1 → SI = Fs_min elif fs.max() < 1: assert si[k] == 0.0 else: assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 q = engine.AreaParams(**SOIL, **SAT) fs = engine.infinite_slope_fs(beta, m, q) si0 = engine.stability_index(beta, m, q) assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) def test_SINMAP_등급_경계(): si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan]) assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0] def test_D8_집수면적_기운평면_V골짜기(): cell = 2.0 ny, nx = 5, 7 xx = np.arange(nx)[None, :] * cell plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # 동쪽으로 기움 → 행마다 동쪽으로 흐름 a = engine.catchment_area(plane, cell) assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell) yy = np.arange(9)[:, None] * cell valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell a = engine.catchment_area(valley, cell) assert a[4, -1] == pytest.approx(9 * nx * cell) # 전부가 골 출구로 assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 def test_습윤지수(): beta = np.radians(np.array([30.0, 30.0, 0.0])) a = np.array([100.0, 1e6, 50.0]) w = engine.wetness(beta, a, 1 / 2000) assert w[0] == pytest.approx(100 / (2000 * 0.5)) # R a / (T sinβ) assert w[1] == 1.0 and w[2] == 1.0 # 넘침 · 평평 = 포화 p = engine.AreaParams(**SOIL, tr_lo=2000, tr_hi=3000) assert engine.x_range(p) == (1 / 3000, 1 / 2000) assert engine.wet_m(beta, a, p).tolist() == w.tolist() # 기본 = 가장 젖은 쪽 assert engine.wet_m(beta, a, engine.AreaParams(**SOIL, **SAT)).tolist() == [1.0] * 3 SINMAP = dict(gamma_t=18.0, gamma_sat=18.0, c=2.0, c_hi=6.0, phi=30.0, phi_hi=40.0) def _sinmap_fs(beta, a, c, tanphi, x, z=1.5, g=18.0): """Pack 1998 식 — FS = [C + cosθ(1 − W r) tanφ] / sinθ · C = c / (h ρs g) · h = z cosθ.""" w = min(x * a / math.sin(beta), 1.0) h = z * math.cos(beta) return (c / (h * g) + math.cos(beta) * (1 - w * 9.81 / g) * tanphi) / math.sin(beta) def test_SI_하한_상한_해석해(): """γt = γsat 이면 엔진 = SINMAP 원식 · 하한 = (c, φ 아래 · T/R 아래) · 상한 = (위 · T/R 위).""" p = engine.AreaParams(**SINMAP, tr_lo=1000, tr_hi=3000) t1, t2 = math.tan(math.radians(30)), math.tan(math.radians(40)) beta = np.radians(np.array([15.0, 33.0, 38.0, 45.0, 70.0])) area = np.array([20.0, 400.0, 300.0, 800.0, 5000.0]) si = engine.stability_index(beta, area, p) rng = np.random.default_rng(7) n = 300_000 c, t, x = rng.uniform(2, 6, n), rng.uniform(t1, t2, n), rng.uniform(1 / 3000, 1 / 1000, n) seen = set() for k, (b, ak) in enumerate(zip(beta, area)): lo = _sinmap_fs(b, ak, 2.0, t1, 1 / 1000) hi = _sinmap_fs(b, ak, 6.0, t2, 1 / 3000) fs = engine.infinite_slope_fs( beta[k : k + 1], engine.wet_m(beta[k : k + 1], area[k : k + 1], p), p ) assert fs[0] == pytest.approx(lo, rel=1e-12) # 결정 Fs = 하한 if lo >= 1: assert si[k] == pytest.approx(lo, rel=1e-12) seen.add("lo") elif hi < 1: assert si[k] == 0.0 seen.add("zero") else: w = np.minimum(x * ak / math.sin(b), 1) h = 1.5 * math.cos(b) mc = (c / (h * 18) + math.cos(b) * (1 - w * 9.81 / 18) * t) / math.sin(b) assert si[k] == pytest.approx((mc > 1).mean(), abs=3e-3) # P(Fs > 1) · T/R 도 범위 seen.add("prob") assert seen == {"lo", "zero", "prob"} def test_시나리오_둘_최악이_더_나쁨(): beta = np.radians(np.linspace(5, 60, 56)) area = np.full_like(beta, 50.0) std = engine.stability_index(beta, area, engine.AreaParams(**SINMAP)) worst = engine.stability_index(beta, area, engine.AreaParams(**SINMAP, **SAT)) assert np.all(worst <= std + 1e-12) # 완전 포화 = 아래 경계 assert (engine.classify(worst) >= engine.RISK_CLASS).sum() > ( engine.classify(std) >= engine.RISK_CLASS ).sum() # 다 젖는 집수(x·a / sinβ ≥ 1) 면 두 시나리오가 같음 big = np.full_like(beta, 1e7) assert np.allclose( engine.stability_index(beta, big, engine.AreaParams(**SINMAP)), worst, rtol=1e-12 ) def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"]) @pytest.fixture() def project(tmp_path, monkeypatch): """가짜 E 복사본 — 서쪽이 낮은 30° 비탈(동서 200 m · 남북 100 m) · 노선 = 남북 중심선.""" x = np.arange(0.0, 200.5, 0.5) y = np.arange(0.0, 100.5, 0.5) z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0) valid = np.ones_like(z, bool) valid[:, x > 180] = False # 동쪽 끝 = 지형 없음 dem = tmp_path / "dtm.npz" np.savez( dem, x=x.astype(np.float32), y=y.astype(np.float32), z=z.astype(np.float32), valid_mask=valid, ) stations = [ {"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)} for i, c in enumerate(range(0, 101, 20)) ] route = [ {"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)} for i, c in enumerate((0, 100)) ] src = router_mod.source monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem) monkeypatch.setattr(src, "stations", lambda _r: stations) monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route}) return tmp_path def test_측점_등급_문턱_경계(): """위험 셀 비율 < 5 % 낮음 · 5 % 이상 20 % 미만 보통 · 20 % 이상 높음 (경계 포함은 윗등급).""" cases = [(0, 100, "low"), (4, 100, "low"), (5, 100, "mid"), (19, 100, "mid")] cases += [(20, 100, "high"), (100, 100, "high"), (1, 20, "mid"), (1, 21, "low"), (1, 5, "high")] for risk, cells, want in cases: assert engine.station_level(risk, cells) == want, (risk, cells) # 사용자가 바꾼 문턱 assert ( engine.station_level(9, 100, 10, 30) == "low" and engine.station_level(30, 100, 10, 30) == "high" ) def test_측점_영향_구역과_비율(): """측점 0 · 10 · 30 → 영향 구역 = 이웃과 중간 누가로 나눔 · 띠 안 셀만 · 자료 없음 제외.""" chain = np.arange(0.0, 40.0, 1.0)[None, :] # 1 × 40 셀 · 셀 i 누가 = i cls = np.ones(chain.shape, np.uint8) cls[0, [0, 1, 2, 3, 4]] = 4 # 측점 0 영역(0~5 · 정확히 중간 5 는 앞 측점) 6 셀 중 5 → 83 % cls[0, [6, 7]] = 5 # 측점 10 영역(6~20) 셀 14(19 는 띠 밖) · 위험 6 · 7 · 20 → 21 % cls[0, [20, 21]] = 6 # 20 은 측점 10 몫 · 측점 30 영역(21~39) 은 21 하나 cls[0, 39] = 0 # 자료 없음 → 셀 수에서 빠짐(18 셀) 1/18 → 5.6 % inside = np.ones(chain.shape, bool) inside[0, 19] = False # 띠 밖 → 빠짐 out = {k: np.ones(chain.shape) for k in ("fs", "si", "slope")} | {"cls": cls} rows = engine.station_table([30.0, 0.0, 10.0], chain, inside, out, 2.0) assert [r["chainage_m"] for r in rows] == [0.0, 10.0, 30.0] assert [r["cells"] for r in rows] == [6, 14, 18] assert [r["level"] for r in rows] == ["high", "high", "mid"] assert rows[0]["risk_ratio"] == round(5 / 6, 4) and rows[1]["risk_ratio"] == round(3 / 14, 4) assert rows[2]["risk_ratio"] == round(1 / 18, 4) and rows[2]["risk_area_m2"] == 4.0 # 문턱을 올리면 등급이 내려감 again = engine.station_table([0.0, 10.0, 30.0], chain, inside, out, 2.0, 15.0, 50.0) assert [r["level"] for r in again] == ["high", "mid", "low"] def test_측점_문턱_설정_검증(): base = router_mod.AreaRequest.model_fields assert (base["level_lo_pct"].default, base["level_hi_pct"].default) == (5.0, 20.0) ok = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=30.0, phi_hi=30.0) router_mod.AreaRequest(**ok, level_lo_pct=10, level_hi_pct=10) with pytest.raises(ValueError): router_mod.AreaRequest(**ok, level_lo_pct=30, level_hi_pct=10) def test_길_띠_측점표_저장본(project): base = router_mod.defaults(project) assert base["scenario"] == "sinmap" and (base["tr_lo"], base["tr_hi"]) == (2000.0, 3000.0) settings = {**base, "band_m": 30.0, "cell_m": 2.0, "scenario": "saturated", "c_root": 0.0} assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 steps = [] summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) doc = router_mod.load(project) assert steps == sorted(steps) and doc["fingerprint"] == "fp" g = doc["grid"] cls = _decode(doc["cls"], "u1", g) fs = _decode(doc["fs"], " 0] assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3) assert summary["fs_min"] == pytest.approx(hand, abs=5e-3) # 띠 = 중심선 ± 30 m(격자 상자도 띠만큼) · 등급 면적 합 = 덮인 면적 assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9 assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"]) assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0) rows = doc["stations"] assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100] assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows)) assert all(r["level"] == "high" and r["risk_ratio"] > 0.99 for r in rows) # 완전 포화 30° 비탈 assert summary["station_levels"] == {"low": 0, "mid": 0, "high": 6} assert sum(r["cells"] for r in rows) == (cls > 0).sum() # 지문 = 설정에 따라 바뀜 assert router_mod.fingerprint("s", settings) != router_mod.fingerprint( "s", {**settings, "c": 6} ) json.dumps(doc) # 그대로 JSON \ No newline at end of file +"""536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. + +무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · +D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · +가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. +""" + +import base64 +import json +import math + +import numpy as np +import pytest + +from E07_StabArea import E07_StabArea_Engine as engine +from E07_StabArea import E07_StabArea_Router as router_mod + +SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) +SAT = dict(scenario="saturated") + + +def _fs(beta_deg: float, m: float, **kw) -> float: + p = engine.AreaParams(**{**SOIL, **kw}) + return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0]) + + +def test_무한사면_해석해_건조_포화_점착(): + t = math.tan(math.radians(35)) / math.tan(math.radians(30)) + assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # 건조 c = 0 → tanφ / tanβ + assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # 포화 → γ' / γsat 배 + # 손계산 — c 5 · 뿌리 2 · z 1.5 · β 35° · m 0.5 + b, z = math.radians(35), 1.5 + w = 18 * z * 0.5 + 20 * z * 0.5 + n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2 + hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b)) + assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12) + assert math.isinf(_fs(0, 1.0)) # 평평 = 활동 없음 + + +def test_SINMAP_안정지수_닫힌꼴_표본(): + p = engine.AreaParams( + gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0, **SAT + ) + beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) + m = np.ones_like(beta) + si = engine.stability_index(beta, m, p) + a, b = engine._terms(beta, m, p) + rng = np.random.default_rng(1) + c = rng.uniform(0, 8, 400_000) + t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000) + for k in range(len(beta)): + lo = 0 * a[k] + math.tan(math.radians(25)) * b[k] + fs = c * a[k] + t * b[k] + if lo >= 1: + assert si[k] == pytest.approx(lo) # Fs_min ≥ 1 → SI = Fs_min + elif fs.max() < 1: + assert si[k] == 0.0 + else: + assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) + assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 + # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 + q = engine.AreaParams(**SOIL, **SAT) + fs = engine.infinite_slope_fs(beta, m, q) + si0 = engine.stability_index(beta, m, q) + assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) + + +def test_SINMAP_등급_경계(): + si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan]) + assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0] + + +def test_D8_집수면적_기운평면_V골짜기(): + cell = 2.0 + ny, nx = 5, 7 + xx = np.arange(nx)[None, :] * cell + plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # 동쪽으로 기움 → 행마다 동쪽으로 흐름 + a = engine.catchment_area(plane, cell) + assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell) + yy = np.arange(9)[:, None] * cell + valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell + a = engine.catchment_area(valley, cell) + assert a[4, -1] == pytest.approx(9 * nx * cell) # 전부가 골 출구로 + assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 + + +def test_습윤지수(): + beta = np.radians(np.array([30.0, 30.0, 0.0])) + a = np.array([100.0, 1e6, 50.0]) + w = engine.wetness(beta, a, 1 / 2000) + assert w[0] == pytest.approx(100 / (2000 * 0.5)) # R a / (T sinβ) + assert w[1] == 1.0 and w[2] == 1.0 # 넘침 · 평평 = 포화 + p = engine.AreaParams(**SOIL, tr_lo=2000, tr_hi=3000) + assert engine.x_range(p) == (1 / 3000, 1 / 2000) + assert engine.wet_m(beta, a, p).tolist() == w.tolist() # 기본 = 가장 젖은 쪽 + assert engine.wet_m(beta, a, engine.AreaParams(**SOIL, **SAT)).tolist() == [1.0] * 3 + + +SINMAP = dict(gamma_t=18.0, gamma_sat=18.0, c=2.0, c_hi=6.0, phi=30.0, phi_hi=40.0) + + +def _sinmap_fs(beta, a, c, tanphi, x, z=1.5, g=18.0): + """Pack 1998 식 — FS = [C + cosθ(1 − W r) tanφ] / sinθ · C = c / (h ρs g) · h = z cosθ.""" + w = min(x * a / math.sin(beta), 1.0) + h = z * math.cos(beta) + return (c / (h * g) + math.cos(beta) * (1 - w * 9.81 / g) * tanphi) / math.sin(beta) + + +def test_SI_하한_상한_해석해(): + """γt = γsat 이면 엔진 = SINMAP 원식 · 하한 = (c, φ 아래 · T/R 아래) · 상한 = (위 · T/R 위).""" + p = engine.AreaParams(**SINMAP, tr_lo=1000, tr_hi=3000) + t1, t2 = math.tan(math.radians(30)), math.tan(math.radians(40)) + beta = np.radians(np.array([15.0, 33.0, 38.0, 45.0, 70.0])) + area = np.array([20.0, 400.0, 300.0, 800.0, 5000.0]) + si = engine.stability_index(beta, area, p) + rng = np.random.default_rng(7) + n = 300_000 + c, t, x = rng.uniform(2, 6, n), rng.uniform(t1, t2, n), rng.uniform(1 / 3000, 1 / 1000, n) + seen = set() + for k, (b, ak) in enumerate(zip(beta, area)): + lo = _sinmap_fs(b, ak, 2.0, t1, 1 / 1000) + hi = _sinmap_fs(b, ak, 6.0, t2, 1 / 3000) + fs = engine.infinite_slope_fs( + beta[k : k + 1], engine.wet_m(beta[k : k + 1], area[k : k + 1], p), p + ) + assert fs[0] == pytest.approx(lo, rel=1e-12) # 결정 Fs = 하한 + if lo >= 1: + assert si[k] == pytest.approx(lo, rel=1e-12) + seen.add("lo") + elif hi < 1: + assert si[k] == 0.0 + seen.add("zero") + else: + w = np.minimum(x * ak / math.sin(b), 1) + h = 1.5 * math.cos(b) + mc = (c / (h * 18) + math.cos(b) * (1 - w * 9.81 / 18) * t) / math.sin(b) + assert si[k] == pytest.approx((mc > 1).mean(), abs=3e-3) # P(Fs > 1) · T/R 도 범위 + seen.add("prob") + assert seen == {"lo", "zero", "prob"} + + +def test_시나리오_둘_최악이_더_나쁨(): + beta = np.radians(np.linspace(5, 60, 56)) + area = np.full_like(beta, 50.0) + std = engine.stability_index(beta, area, engine.AreaParams(**SINMAP)) + worst = engine.stability_index(beta, area, engine.AreaParams(**SINMAP, **SAT)) + assert np.all(worst <= std + 1e-12) # 완전 포화 = 아래 경계 + assert (engine.classify(worst) >= engine.RISK_CLASS).sum() > ( + engine.classify(std) >= engine.RISK_CLASS + ).sum() + # 다 젖는 집수(x·a / sinβ ≥ 1) 면 두 시나리오가 같음 + big = np.full_like(beta, 1e7) + assert np.allclose( + engine.stability_index(beta, big, engine.AreaParams(**SINMAP)), worst, rtol=1e-12 + ) + + +def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: + return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"]) + + +@pytest.fixture() +def project(tmp_path, monkeypatch): + """가짜 E 복사본 — 서쪽이 낮은 30° 비탈(동서 200 m · 남북 100 m) · 노선 = 남북 중심선.""" + x = np.arange(0.0, 200.5, 0.5) + y = np.arange(0.0, 100.5, 0.5) + z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0) + valid = np.ones_like(z, bool) + valid[:, x > 180] = False # 동쪽 끝 = 지형 없음 + dem = tmp_path / "dtm.npz" + np.savez( + dem, + x=x.astype(np.float32), + y=y.astype(np.float32), + z=z.astype(np.float32), + valid_mask=valid, + ) + stations = [ + {"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)} + for i, c in enumerate(range(0, 101, 20)) + ] + route = [ + {"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)} + for i, c in enumerate((0, 100)) + ] + src = router_mod.source + monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem) + monkeypatch.setattr(src, "stations", lambda _r: stations) + monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route}) + return tmp_path + + +def test_측점_등급_문턱_경계(): + """위험 셀 비율 < 5 % 낮음 · 5 % 이상 20 % 미만 보통 · 20 % 이상 높음 (경계 포함은 윗등급).""" + cases = [(0, 100, "low"), (4, 100, "low"), (5, 100, "mid"), (19, 100, "mid")] + cases += [(20, 100, "high"), (100, 100, "high"), (1, 20, "mid"), (1, 21, "low"), (1, 5, "high")] + for risk, cells, want in cases: + assert engine.station_level(risk, cells) == want, (risk, cells) + # 사용자가 바꾼 문턱 + assert engine.station_level(9, 100, 10, 30) == "low" + assert engine.station_level(30, 100, 10, 30) == "high" + + +def test_측점_영향_구역과_비율(): + """측점 0 · 10 · 30 → 영향 구역 = 이웃과 중간 누가로 나눔 · 띠 안 셀만 · 자료 없음 제외.""" + chain = np.arange(0.0, 40.0, 1.0)[None, :] # 1 × 40 셀 · 셀 i 누가 = i + cls = np.ones(chain.shape, np.uint8) + cls[0, [0, 1, 2, 3, 4]] = 4 # 측점 0 영역(0~5 · 정확히 중간 5 는 앞 측점) 6 셀 중 5 → 83 % + cls[0, [6, 7]] = 5 # 측점 10 영역(6~20) 셀 14(19 는 띠 밖) · 위험 6 · 7 · 20 → 21 % + cls[0, [20, 21]] = 6 # 20 은 측점 10 몫 · 측점 30 영역(21~39) 은 21 하나 + cls[0, 39] = 0 # 자료 없음 → 셀 수에서 빠짐(18 셀) 1/18 → 5.6 % + inside = np.ones(chain.shape, bool) + inside[0, 19] = False # 띠 밖 → 빠짐 + out = {k: np.ones(chain.shape) for k in ("fs", "si", "slope")} | {"cls": cls} + rows = engine.station_table([30.0, 0.0, 10.0], chain, inside, out, 2.0) + assert [r["chainage_m"] for r in rows] == [0.0, 10.0, 30.0] + assert [r["cells"] for r in rows] == [6, 14, 18] + assert [r["level"] for r in rows] == ["high", "high", "mid"] + assert rows[0]["risk_ratio"] == round(5 / 6, 4) and rows[1]["risk_ratio"] == round(3 / 14, 4) + assert rows[2]["risk_ratio"] == round(1 / 18, 4) and rows[2]["risk_area_m2"] == 4.0 + # 문턱을 올리면 등급이 내려감 + again = engine.station_table([0.0, 10.0, 30.0], chain, inside, out, 2.0, 15.0, 50.0) + assert [r["level"] for r in again] == ["high", "mid", "low"] + + +def test_측점_문턱_설정_검증(): + base = router_mod.AreaRequest.model_fields + assert (base["level_lo_pct"].default, base["level_hi_pct"].default) == (5.0, 20.0) + ok = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=30.0, phi_hi=30.0) + router_mod.AreaRequest(**ok, level_lo_pct=10, level_hi_pct=10) + with pytest.raises(ValueError): + router_mod.AreaRequest(**ok, level_lo_pct=30, level_hi_pct=10) + + +def test_길_띠_측점표_저장본(project): + base = router_mod.defaults(project) + assert base["scenario"] == "sinmap" and (base["tr_lo"], base["tr_hi"]) == (2000.0, 3000.0) + settings = {**base, "band_m": 30.0, "cell_m": 2.0, "scenario": "saturated", "c_root": 0.0} + assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 + steps = [] + summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) + doc = router_mod.load(project) + assert steps == sorted(steps) and doc["fingerprint"] == "fp" + g = doc["grid"] + cls = _decode(doc["cls"], "u1", g) + fs = _decode(doc["fs"], " 0] + assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3) + assert summary["fs_min"] == pytest.approx(hand, abs=5e-3) + # 띠 = 중심선 ± 30 m(격자 상자도 띠만큼) · 등급 면적 합 = 덮인 면적 + assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9 + assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"]) + assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0) + rows = doc["stations"] + assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100] + assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows)) + assert all(r["level"] == "high" and r["risk_ratio"] > 0.99 for r in rows) # 완전 포화 30° 비탈 + assert summary["station_levels"] == {"low": 0, "mid": 0, "high": 6} + assert sum(r["cells"] for r in rows) == (cls > 0).sum() + # 지문 = 설정에 따라 바뀜 + assert router_mod.fingerprint("s", settings) != router_mod.fingerprint( + "s", {**settings, "c": 6} + ) + json.dumps(doc) # 그대로 JSON From 03beb7f7c9b81e72ecd267ed74a862032cc99676 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:33:37 +0900 Subject: [PATCH 29/34] =?UTF-8?q?fix(532-1):=20E01=20Source=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=ED=96=89=20sequence=5Fnum=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EC=9D=BD=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?row.get=20=EC=9C=BC=EB=A1=9C=20=EC=B0=A8=EB=A1=80=20=3D=20chain?= =?UTF-8?q?age=20=EC=A0=95=EB=A0=AC=20=C2=B7=20test=5F532=5F3=5Fe02=5Fgrou?= =?UTF-8?q?nd=202=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4=EC=86=8C(=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- E01_StabRegister/E01_StabRegister_Source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/E01_StabRegister/E01_StabRegister_Source.py b/E01_StabRegister/E01_StabRegister_Source.py index c8e5d9261..70bb98bb7 100644 --- a/E01_StabRegister/E01_StabRegister_Source.py +++ b/E01_StabRegister/E01_StabRegister_Source.py @@ -71,7 +71,7 @@ def db_snapshot(root: Path) -> dict[str, Any]: def _cross_rows(root: Path) -> list[dict[str, Any]]: rows = [row for row in db_snapshot(root)["cross_sections"] if row["chainage_m"] is not None] - return sorted(rows, key=lambda row: (float(row["chainage_m"]), row["sequence_num"] or 0)) + return sorted(rows, key=lambda row: (float(row["chainage_m"]), row.get("sequence_num") or 0)) def _row_data(row: dict[str, Any]) -> dict[str, Any]: From 85df6e4a091fb134c017274fd355d9a18582003b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:35:35 +0900 Subject: [PATCH 30/34] =?UTF-8?q?chore(=EA=B3=84=ED=9A=8D):=20PLAN=20528?= =?UTF-8?q?=20=C2=B7=20529=20=C2=B7=20530=20=C2=B7=20534=20=C2=B7=20535=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=A0=88=20=EC=9D=B4=EA=B4=80(=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20348)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/plans/2026-10-25_plan_528-535.md | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/raw/plans/2026-10-25_plan_528-535.md diff --git a/docs/raw/plans/2026-10-25_plan_528-535.md b/docs/raw/plans/2026-10-25_plan_528-535.md new file mode 100644 index 000000000..8035c3a36 --- /dev/null +++ b/docs/raw/plans/2026-10-25_plan_528-535.md @@ -0,0 +1,63 @@ +# 계획서 이관 — 528 · 529 · 530 · 534 · 535 (2026-10-25 · 요구 348 · 완료 절) + +## 528. 신규 프로젝트 그룹 공통 — 종류 · [+] 네 카드 · 산림청 · 공유 보기 · 복사 (요구 345 · 346 · 요구서 345 2장 · 528-1 · 3 · 5 sub1 · 528-2 sub2 · Opus high · 528-4 뒤) + +- 사용자 확정(요구 345 · 346): [+] = 예정노선 · 임도설계 · 사방설계 · 안정성평가 카드(그림 + 제목) · 예정노선 = 산림청(*.go.kr) · 시스템관리자만 · 지정 업체 보기만 · 연결 · 생성 시점 복사 · E 업데이트 없음. +- 정함(브레인 · 사용자 바꿀 수 있음): 요구서 345 6장(산림청 = go.kr 전부 · 기관도 회사로 등록 · 지정 회사 구성원 전부 보기 · 공유 판정은 공용 접근 판정 한 곳). +- [x] 528-1 DB 020(projects 종류 · 사업연도 끝 · 원본 칸 + 예정노선 연도 구간 · 지정 업체 표) · 산림청 판정 함수 · B02 API 종류 검사 — 지킴 시험(판정 · 일반 사용자 PLAN 403 · 원본 B06 미완료 400). + - 검증완료 — 시험 19 통과 · 기존 B01 · 접근 · 로그인 63 통과 · 실 API: 일반 D01 403 · 산림청 PLAN(속도 20 · 단계 3 · D 폴더) · E 원본 B06 미완료 400 · 완료 = STABILITY 단계 4 · 공유 DB 에 020 적용(기존 16행 ROAD) +- [x] 528-2 [+] 네 카드 모달 · B01 종류 열 · 종류별 첫 화면 · 종류별 단계 시드 · storage 폴더 허용 — ORCA 세 역할(산림청 · 업체 · 시스템관리자). + - 검증완료 — 실 API 세션 산림청(go.kr 임시 계정) · 시스템관리자 = 참 · D01 200 / 회사관리자 · 사용자 = 거짓 · D01 403 · ORCA 모달: 시스템관리자 카드 넷 켬(사방만 0.45) · 회사관리자 · 사용자 = 예정노선 0.45 + 까닭 툴팁 · 누름 알림 · 어두움 선 색 = 글자 색 · 표 종류 열 · PLAN 행 단계 누름 → D03 · 받은 예정노선 2줄 · [보기] → D02 · E01 → E02 · DB STABILITY 단계 4 · 만든 행 모두 지움 +- [x] 528-3 공유 회사 GET 만 판정 · 대시보드 「받은 예정노선」 구역 — 시험(공유 GET 200 · PUT 403 · 미지정 403) + ORCA. + - 검증완료 — 실 API: 지정 회사 GET 200 · PUT 403 · 미지정 GET 403 · 받은 예정노선 1건(지정 회사) · 0건(그 밖) · 화면 구역은 528-2 +- [x] 528-4 공용 복사 함수(복제 도구 본체 이전 · CLI 껍데기 · 범위 인자) — 임시 storage 시험(새 id · UUID 치환 · 원본 그대로). + - 검증완료 — 시험 2 통과(full: 표 7 행 수 같음 · 지표면 id 다시 이음 · UUID 0 · 원본 해시 그대로) · 옛 도구 대 새 CLI 같은 원본: 표 9 · 파일 234 같음(단계 params 는 자동증가 id 만 다름) +- [x] 528-5 회원가입 메일 *.go.kr → 회사 「산림청」 자동 입력 · 고정(요구 347-8) + 산림청 회사 행 미리 둠 — 지킴 시험(go.kr 가입 = 산림청 회사 · 다른 회사 보내도 무시) + ORCA 가입 화면. + - 검증완료 — 시험: go.kr 인증 = 산림청 · ACTIVE · 보낸 회사 값 무시 · 다른 메일 NO_COMPANY · ORCA 가입 화면: Kim@Forest.GO.KR → 회사 칸 「산림청」 readOnly 보임 · gmail → 숨김 +- [x] 528-6 D · E 생성 가볍게(요구 351) — B 양식 복사 · B0x 빈 폴더 없음(D = D0x + B04_PreProcess · E = E0x) · 경로 풀이가 D · E 루트에 B 폴더 안 만듦 · ROAD 그대로 · 기존 검증 D · E 셋 정리. + - 검증완료 — 시험 528 25 · D03 지형 · 분석 · E01 복사 19 통과 · 실 DB 생성: E 31.03 MB(복사 20.27 + 양식 10.76) → 20.27 MB · B 폴더 0 · D 10.76 MB → 0 MB(D02~D04 + B04) · 기존 셋(검증_sub7 · 검증531 · 검증_sub8) 양식 · 빈 B 폴더 지움 · 사용자 D · E 없음 + +## 529. D 예정노선 — 등록 · 1단계 기준 지정 (D01 · D02 · 요구서 345 3-1 · 3-2 · 529-1 · 2 sub2 · Opus high · 529-3 뒤) + +- 사용자 확정(요구 345 · 349): 등록 칸(사업연도 범위 · 지역 · 종류 「노선 계획」 · 임도 종류 · 설계속도(기본 최저 속도) · 이름 수기/자동 · 시작 · 종료 누가 · 도면 칸 없음) · 연도별 시점 · 종점(직전 종점 = 다음 시점) · 카카오 끌기 · 좌표 입력 · B05~B09 틀 재활용. +- 정함(브레인): 틀 아이콘 · [<][>] · 도움말 인자 · 카카오 지도 ui_template 이전 · 작업 좌표계 경도 자동(5185~5187) · 변환 서버 pyproj · 근거 _분석_345-B. +- [x] 529-1 틀 인자 + D 경로 + D01 등록(B02 이름 조합기 · 임도 종류 · 설계속도 = B02 칸 · resolve_design_speed 재활용) — ORCA 등록 → DB 행 · 이름 자동 · 속도 기본 20 · B05 틀 그대로. + - 검증완료 — ORCA D01: 속도 기본 20(간선 20/30/40 · 작업 20) · 이름 「2026~2028 확인군 작업임도」 자동 · 손댄 뒤 지역만 갈아 끼움 · 등록 → DB PLAN 2026~2028 · fire 30 · 0~2500 · 단계 3 → D02 · D/E [<][>] 묶음 안(D04 · E05 [>] 흐림) · [?] D03 · B05 틀 그대로(B 아이콘 · [<] 흐림 · [?] 14 항목) · 시험 280-2 · 54-2 · 218-1 통과 +- [x] 529-2 카카오 지도 ui_template 이전 · 키 길 공용 — B08 이정표 시험 + ORCA B08 지도 그대로. + - 검증완료 — B08 이정표 시험 99 통과 · ORCA B08 거래처 모달 지도: /api/map/kakao-key 로 SDK 적재 · 칸 높이 480 · 시점 알약 그대로 +- [x] 529-3 좌표 변환 common_util_crs + D02 지점 정본 API(잇기 · 빈 연도 거절) + D02 화면(연도 표 · 좌표 칸 · 끌기 · 구간 선 · [>] 저장) — 지킴 시험(왕복 1 mm · 잇기) + ORCA 끌기 ↔ 표 수치. + - 검증완료 — 시험 13 통과(원점 3 띠 · 왕복 1 mm · 띠 경계 · 잇기 · 빈 연도 · 범위 · 권한 · 바뀐 연도만 비움) · 실 API 빈 연도 400 · 범위 밖 400 · 임도 프로젝트 400 · 다른 회사 403 · ORCA 지도 누름 3번 = 시점 → 경계 → 종점 차례 · 끌기 P1 → 표 127.125 / 37.515 · 입력 → 표지 127.165 · 연도 선 빨강 · 파랑 · [>] 뒤 D03 · DB 2027 시점 = 2026 종점 (211050.971, 546176.272) EPSG 5186 = pyproj 값 · 만든 프로젝트 지움 + +## 530. D 예정노선 — 지형 받기 · 2단계 분석 (D03 · 요구서 345 3-3 · 530-1 · 2 sub3 · Opus high · 530-3 뒤) + +- 사용자 확정(요구 345): [>] 때 수치지형도 외부 API 받아 영구저장 · 범위 넘으면 더 받기 · [분석] = B05 추천 로직 한 벌 · 보임 = 중심선 · 측점 스냅 · 표기 · 지반 횡단 · 3D(구조물 X) · 노견 · 절성토선 · 종단 · 배수유역 X. +- 정함(브레인): 추천 기준 = 등록 임도 종류 · 설계속도 · 지형 = D storage 안 B04_PreProcess 배치 · DEM 연도 구간별 · 갈래 넷 연도마다 고름 · 연도 경계 꺾인 채 · 계류 이격 반영 · 복도 폭 D 만 넓힘(B 기본 그대로) · 근거 _분석_345-B. +- [x] 530-1 지형 받기 서비스(합집합 · prune 없음 · 지점 사이 메움 · 위성) + 연도별 DEM · 모델 행 — 가짜 내려받기 시험(옛 도엽 유지 · 새 도엽만 · 빠짐 없음). + - 검증완료 — 시험 3 통과 · 실측(검증_sub3 노선 근처 두 연도 1.23 · 1.22 km · 5187) 받기 123 s · 도엽 12매 · 위성 1장 · DEM 726×1935 · 1700×1400 칸 · 다시 받기 0매 · 위성 건너뜀 +- [x] 530-2 B05 엔진 인자 셋(models_dir · cache_dir · 복도) + D 분석 API(구간 반복 · 잇기 · 누가 · 진행률 · 저장본) — B05 추천 시험 그대로 통과 + 경계 좌표 일치 시험. + - 검증완료 — B05 추천 시험 11파일 그대로 통과 · D 시험 7 통과 · 실측 두 구간 갈래 넷 복도 100 · 200 · 300 m = 116 · 140 · 183 s · 못 지킨 곳 5 · 8 · 1 → 복도 300 m · 같은 입력 다시 0.07 s · 경계 좌표 일치 +- [x] 530-3 D03 평면(MapRender · 측점 · 스냅 · 지점 끌기 · 범위 밖 더 받기) + 지반만 횡단 + 3D 모달 — ORCA(측점 · SVG 표고 = 격자 · 구조물 0). + - 검증완료 — B05 횡단 시험 48 · D 시험 30 통과 · typecheck 0 · ORCA 산림청 계정 새 예정노선(2026~2027): 열자 받기 도엽 9매 · 갈래 넷 카드 · 2027 토공 최소 고름 → [저장] DB 갈래 · 누가 2861.19~4218.94 = 화면 · 측점 150+0 누름 SVG 지반선 = 격자 표고 차 0.000001 m · 3D 코리도 · 구조물 0 · 종점 1 km 끌기 → 범위 밖 · 더 받기 도엽 9 → 12매 · 끈 지점 [분석] · [저장] = 정본 종점 · 노선 끝 누가 5725.28 · 프로젝트 지움 + +## 534. E 안정성평가 — 내장 엑셀 보고서 (E05_StabReport · 요구 347-5 · 534-1 · 2 sub6 · Opus high · PDF 는 535 공용 모듈 뒤 · 534-3 뒤) + +- 사용자 확정(요구 347-5): 내장 엑셀 양식화 · 페이지 구분 · 내용 정리 · 사용자 고침 · 보기 = 내장 엑셀 · 내보내기 = xlsx · PDF. +- 정함(브레인 · 사용자 바꿀 수 있음): 시트 여섯(표지 · 요약 = 사용자 몫 · 입력 조건 · 측점표 · 대표 단면 · 3D 요약 = 프로그램 몫) · [보고서 갱신] = 프로그램 몫 시트 · 그림만 다시(사용자 몫은 수식으로 따라감 · 옛 판 _지난/) · PDF = 인쇄 HTML → B09 Edge 엔진 공용화 · 그림 = 서버 SVG → PNG(Playwright · PyMuPDF 안 씀 — AGPL) · 양식 = master_template/report/stab_report.json · 대표 단면 = 자동(쌓기 10 m · 깎기 15 m · 최소 안전율 하위 5 · E03 에서 고침) · 칸 잠금 없음 · 내장 엑셀 계약 고침 = 534 창만 · 근거 _분석_345-F(그 파일의 E04 = E05). +- [x] 534-1 내장 엑셀 계약 더함(인쇄 설정 · 쪽 나눔 · 그림 칸) + 그림 겹판 · 쪽 나눔 점선 — spreadsheet 시험 통과 + 크기 예산. + - 검증완료 — 엔진 시험 9 · 크기 52.0 KB/128 · 행 넣기 때 쪽나눔 4→5 · 그림 B8:F12→B9:F13 · 겹판 칸 자리 0 px 차(스크롤 포함) · M02 면적산출 그대로 +- [x] 534-2 PDF 엔진 공용화(B09 는 부르기만) + 인쇄 HTML(TS · Node) + Workbook → xlsx — B09 인쇄 시험 그대로 + 되읽기 시험(병합 · 쪽 나눔 · 그림 · 머리 줄 반복). + - 검증완료 — 인쇄 HTML 덩이 3 · thead 1줄씩 · 병합 잘림 2+2 · xlsx 되읽기(병합 2 · row_breaks [6,12] · 그림 1 · $2:$2 · 식) · PDF 3쪽 = 쪽 나눔 2 + 1(가로) · PDF 엔진 = sub8 535-1 공용 모듈 +- [x] 534-3 보고서 양식 · 빌더 · 길 · E05 화면([저장] · [초기화] · [보고서 갱신] · [엑셀] · [PDF]) — 시험(측점 수 = 줄 수 · 사용자 몫 불변) + ORCA(PDF 쪽 수). + - 검증완료 — 양식(sub7 · 시트 여섯 · 자리표) + 빌더 · 길 · 화면 시험 7(측점 30 = 줄 30 · N.G 4 · 그림 4 · 갱신 뒤 표지 · 개요 그대로 · 덮임 [측점표] · 판 409 · xlsx 되읽기 · PDF 쪽 = 3 + 측점표 n + 단면 3 + 1) · ORCA 일반 계정 · 검증_sub6 복사 E(측점 57 · 2D = 같은 엔진 · 정한 저장본 꼴): 시트 6 · 단면 그림 3 · 표지 C10 · C12 고침 → [보고서 갱신] 뒤 남음 · 측점표 고침 = 덮임 알림 · 해석 바뀜 띠 · [초기화] · PDF 10쪽(3 + 측점표 3 + 단면 3 + 3D 1) · xlsx 시트 6 · 측점 줄 57 · N.G 28 · E 지움 +- [x] 534-4 E 전 흐름 확인(요구 345 · 347 · 351 · sub1) — 일반 계정 회사 3 · [+] → E01 → E05 · 원본 바꿔도 불변 · 다른 회사 403 · 시스템 관리자 보기 · 숫자 맞춤 · 고침 = E05 가 E03 정본 저장본(stab2d_summary · stab2d_station · 대표 켬/끔)을 바로 읽음 · 3D요약 같은 조건 비교 · E04 판정 · 입력조건 적용 구간. + - 검증완료 — 검증_sub1 회사 3 사본 → E 생성 3.0 s(화면 ≤ 5.8 s) · 16 MB · 측점 63 = 원본 63 − missing 0 · E03 182.6 s · 건기 N.G 13 · 우기 30(화면 같음) · 임계면 SVG 6점 = API · E05 측점 줄 63 · N.G 30 · 대표 17 = E03 켬 · 입력조건 = E02 저장값(c 12 · 점토층 · 0~100 m · 노면 12) · 3D/2D 1.7314 = 0.838/0.484 · 광역요약 면적 = E07 · xlsx 시트 7 · PDF 25쪽 · 원본 B06 바꿈 = E 지문 그대로 · 회사 1 E 403 · 관리자 200 · 시험 40 · typecheck · 만든 E 둘 · 사본 지움 · 남음 = E04 화면 2D 최소 · 비 비어 보임(fs_min 칸 없음 · sub7) +- 공통(532~534): 해석 엔진 직접 만듦 · 외부는 BSD · MIT · Apache 만(요구 350 · 요구서 345 9장) · GPL · AGPL · LGPL · 비상업 코드 금지. + +## 535. 라이센스 — PDF 엔진 공용화 · B09 도면 PDF 의 PyMuPDF(AGPL) 교체 (요구 350 · 351 · sub8 · Opus high) + +- 사용자 확정(요구 351): 상용 가능한 무료 PDF 라이브러리 중 품질 좋은 것은 브레인이 정함 · B 는 어쩔 수 없을 때만 고침(라이센스라 고침). +- 정함(브레인): 만들기 = Playwright(Chromium PDF · Apache-2.0 · 벡터 · 글꼴 묻음 · B09 인쇄와 같은 엔진) · 합치기 · 쪽 다루기 = pypdf(BSD · 설치됨) · 큰 도면에서 느리면(측정) ReportLab(BSD) · PyMuPDF 는 제품 코드에서 없앰(지식DB 변환 스크립트는 개발 도구라 둠). +- [x] 535-1 PDF 엔진 공용화(B09 인쇄 함수 → common_util/common_util_print_pdf.py · B09 는 부르기만) — B09 인쇄 시험 그대로. + - 검증완료 — test_302_1_print_frame 16 통과 · 공용 render_html_to_pdf · render_htmls_to_pdfs · merge_pdfs · sheet_title · print_setup +- [x] 535-2 B09 도면 PDF PyMuPDF → SVG + Playwright · pypdf — 시험(쪽 수 · 글자 · 선 수) + 전후 그림 대조 · 시간 · 제품 폴더 pymupdf 0. + - 검증완료 — 검증_sub8 9장(평면도 지형 = 옛 판도 못 읽음 · 뺌) 쪽 9/9 · 글자 9장 모두 같음 · 선 조각 ±15 % · 쪽 그림 차 0.9~3 %(글 0.76 pt 밀림 · 토적도 세운 글 116 개 = 화면 방향으로 바로잡힘 · 옛 판은 뒤집힘) · 크기 391 → 413 KB · 렌더 1.1 → 2.4 s(브라우저 띄움 +1.5 s · 장당 0.145 s 같음) · 큰 장(선 3만 · 글 1만) 22.5 → 13.8 s · test_302_1 16 · 302_2 8 통과 · ORCA 인쇄 미리보기 9쪽 · 제품 폴더 pymupdf 0 From ab3eee034d18cd0a91b23d830f9f26ad0d79d89a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 23:59:39 +0900 Subject: [PATCH 31/34] =?UTF-8?q?feat(536-3):=20E=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EC=95=9E=EB=B6=80=EB=B6=84=20=E2=80=94=20?= =?UTF-8?q?=EB=B2=BD=20=EC=99=B8=EC=A0=81=20=EC=95=88=EC=A0=95(=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=C2=B7=20=EC=A0=84=EB=8F=84=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=A7=80=EB=A0=A5=20=C2=B7=20KDS=2011=2080=2005)=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=ED=83=88=20=EC=99=84=ED=99=94=20=C2=B7=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=20=EA=B0=80=EC=A0=95=20=EC=9E=AC=ED=95=B4=EC=84=9D=20?= =?UTF-8?q?=C2=B7=20=EC=B5=9C=EC=86=8C=20=EC=99=84=ED=99=94=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20E03=20=E3=80=8C=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=E3=80=8D=20=ED=83=AD=20=C2=B7=20E05=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=EA=B2=80=ED=86=A0=20=EC=8B=9C=ED=8A=B8(?= =?UTF-8?q?=EC=9E=88=EC=9D=84=20=EB=95=8C=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - E03_StabSection_WallCheck: Coulomb · Rankine · M-O 토압 · 상재하중 · 수압 · 양압력 · Meyerhof 지지력 · 평상시 / 지진 시 판정 - E03_StabSection_WhatIf: 한 쪽 비탈 1:n(+ 소단) 다시 그림 · 원지반 만나는 가장 완만한 n · 성긴 걸음 + 이분 최소 완화(완만할수록 늘 안전하지 않음) - E03_StabSection_Router_WhatIf: POST whatif · wallcheck · GET whatif · 저장본 _derived/stab2d_whatif(+ _view) · 원본 측점 · 요약 그대로 - E05: s8 보강검토(양식 · 자리표) · 저장본 없으면 시트 뺌 · 갱신 때 사라진 프로그램 몫 뺌 - 시험 test_536_3 12 · 534-3 양식 시험 시트 8(보강검토 빈 판 1쪽) Co-Authored-By: Claude Opus 5.5 --- .../E03_StabSection_Router_WhatIf.py | 345 ++++++++++++++++++ E03_StabSection/E03_StabSection_UI_Page.ts | 73 +++- .../E03_StabSection_UI_Reinforce.ts | 314 ++++++++++++++++ E03_StabSection/E03_StabSection_WallCheck.py | 220 +++++++++++ E03_StabSection/E03_StabSection_WhatIf.py | 199 ++++++++++ E05_StabReport/E05_StabReport_Build.py | 12 +- E05_StabReport/E05_StabReport_Reinforce.py | 135 +++++++ E05_StabReport/E05_StabReport_Store.py | 11 +- main.py | 2 + .../master_template/report/stab_report.json | 345 ++++++++++++++++++ .../report/stab_report_slots.md | 10 + .../tester/test_534_3_stab_report_form.py | 9 +- resources/tester/test_536_3_e03_reinforce.py | 328 +++++++++++++++++ ui_template/ui_template_locale_e03.ts | 44 +++ 14 files changed, 2038 insertions(+), 9 deletions(-) create mode 100644 E03_StabSection/E03_StabSection_Router_WhatIf.py create mode 100644 E03_StabSection/E03_StabSection_UI_Reinforce.ts create mode 100644 E03_StabSection/E03_StabSection_WallCheck.py create mode 100644 E03_StabSection/E03_StabSection_WhatIf.py create mode 100644 E05_StabReport/E05_StabReport_Reinforce.py create mode 100644 resources/tester/test_536_3_e03_reinforce.py diff --git a/E03_StabSection/E03_StabSection_Router_WhatIf.py b/E03_StabSection/E03_StabSection_Router_WhatIf.py new file mode 100644 index 000000000..85ae4723f --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router_WhatIf.py @@ -0,0 +1,345 @@ +"""E03 보강 검토 API — 비탈 완화 · 소단 가정 재해석 · 벽 외적 안정 (요구 345 · 351 · PLAN 536-3). + +- `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 · 최소 완화 찾기) + → 형상만 바꾼 모형으로 `analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴) +- `POST …/stab/wallcheck/{station_key}` — 그 측점 벽마다 활동 · 전도 · 지지력(평상시 · 지진 시) +- `GET …/stab/whatif` — 지금 2D 결과에 붙은 가정 · 벽 검토 목록(단면 꼴 뺌 · E05 「보강검토」 시트) + +가정 결과 = 파생 저장본 `_derived/stab2d_whatif/<지문>` + 단면 꼴 `stab2d_whatif_view/` (원본 결과 그대로) · +지문 = 원본 측점 지문 + 가정. 엔진 = 부르기만(`analyze_station` · `section_model_from_b06`). +계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import math +import time +from pathlib import Path +from typing import Any, Literal +from functools import lru_cache +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from shapely.geometry import Point, Polygon + +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E02_StabGround import E02_StabGround_Router as e02 +from E03_StabSection import E03_StabSection_Router as e03 +from E03_StabSection import E03_StabSection_View as view +from E03_StabSection import E03_StabSection_Walls as walls_mod +from E03_StabSection import E03_StabSection_WallCheck as wallcheck +from E03_StabSection import E03_StabSection_WhatIf as whatif +from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL, section_model_from_b06 +from E03_StabSection.E03_StabSection_Engine_Station import analyze_station + +router = APIRouter(prefix="/api/projects", tags=["E03 Stab What-if"]) +KIND = "stab2d_whatif" +KIND_VIEW = "stab2d_whatif_view" # 바뀐 단면 화면 꼴(무거움 · 목록은 안 읽음) +KEEP = 300 + + +@lru_cache(maxsize=1) +def code_hash() -> str: + """가정 · 벽 검토 코드 지문 — 바뀌면 저장본을 다시 셈(엔진 지문은 원본 측점 지문에 있음).""" + digest = hashlib.sha1() + for name in ("E03_StabSection_WhatIf.py", "E03_StabSection_WallCheck.py", Path(__file__).name): + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +class WhatIfRequest(BaseModel): + side: Literal["left", "right"] + ratio: float | None = Field(None, gt=0.05, le=whatif.MAX_RATIO) + bench_w: float = Field(0.0, ge=0.0, le=10.0) + bench_h: float = Field(0.0, ge=0.0, le=30.0) + search: bool = False + #: 최소 완화 찾기 목표 — 조건 하나(None = 모든 조건) · 목표 안전율(None = 그 조건 기준안전율) + condition: Literal["dry", "wet", "seismic"] | None = None + target_fs: float | None = Field(None, gt=0.5, le=5.0) + + +class WallRequest(BaseModel): + method: Literal["coulomb", "rankine"] = "coulomb" + water_ratio: float = Field(0.0, ge=0.0, le=1.0) + kh: float | None = Field(None, ge=0.0, le=0.5) + + +def _context(root: Path, station_key: str) -> dict[str, Any]: + """측점 하나 — 지금 입력과 같은 2D 결과 · 원본 저장본 · 엔진 입력(일꾼과 같은 값).""" + ground = e03._ground(root) + plan = e03.plan_run(root, ground) + summary, fresh = e03._latest_summary(root, plan["fp"]) + if not summary or not fresh: + raise HTTPException(status_code=409, detail="2D 결과가 지금 입력과 다릅니다 — 먼저 [해석].") + row = next((r for r in summary["stations"] if r["key"] == station_key), None) + saved = read_derived(root, e03.KIND_STATION, row["fp"]) if row else None + if not saved or not saved["value"]["out"]: + raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.") + value = saved["value"] + cross = src.cross_section(plan["source"], row["chainage_m"]) + entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None) + shared = plan["shared"] + kw = { + "materials": shared["materials"], + "conditions": list(shared["conditions"]), + "seismic_kh": shared["seismic_kh"], + "road_surcharge_kpa": shared["road_surcharge_kpa"], + "layers": value["inputs"]["layers"], + "ground": [tuple(p) for p in value["inputs"]["ground"]], + } + walls = walls_mod.walls_of(entry) + if walls: + kw["walls"] = walls + return { + "ground": ground, + "plan": plan, + "row": row, + "value": value, + "cross": cross, + "design": walls_mod.design_with_walls(cross["design"], entry), + "walls": walls or [], + "kw": kw, + } + + +def _wall_side(wall: dict[str, Any]) -> str: + return "left" if Polygon(wall["polygon"]).centroid.x > 0 else "right" + + +def _section(ctx: dict[str, Any], design: dict[str, Any], row: dict, out: dict) -> dict: + """바뀐 단면 화면 꼴 — `GET …/section` 과 같은 꼴(지하수선 = E02 고른 갈래).""" + kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa")} + kw["walls"] = ctx["kw"].get("walls") + dry = section_model_from_b06(ctx["cross"], design, **kw) + water = {} + for cond in ctx["kw"]["conditions"]: + model = section_model_from_b06(ctx["cross"], design, wet=cond == "wet", **kw) + gw = ground_model.station_groundwater(ctx["ground"], cond, model) + water[cond] = gw.get("line") if gw.get("mode") == "line" else None + return view.section_view(row, out, dry, water) + + +def _side_cases(row: dict[str, Any], side: str) -> dict[str, Any]: + return {cond: by[side] for cond, by in (row.get("cases") or {}).items() if side in by} + + +def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, Any]: + ctx = _context(root, station_key) + row, design, ground = ctx["row"], ctx["design"], ctx["kw"]["ground"] + role = {s["side"]: s["role"] for s in row["sides"]}.get(req.side) + if role is None: + raise HTTPException(status_code=422, detail="그 쪽 비탈이 없습니다.") + if any(_wall_side(w) == req.side for w in ctx["walls"]): + raise HTTPException(status_code=422, detail="벽이 선 비탈 — 벽 외적 안정으로 검토하세요.") + before_ratio = whatif.current_ratio(design, req.side, role) + assume = { + "side": req.side, + "role": role, + "ratio": float(req.ratio or before_ratio), + "bench_w": req.bench_w if req.bench_h > 0 else 0.0, + "bench_h": req.bench_h if req.bench_w > 0 else 0.0, + } + goal = {"condition": req.condition, "target_fs": req.target_fs} if req.search else None + key = derived_key(code_hash(), row["fp"], assume, goal) + cached, shown = read_derived(root, KIND, key), read_derived(root, KIND_VIEW, key) + if cached and shown: + return {**cached["value"], "view": shown["value"]} + t0 = time.perf_counter() + meta, factors = ctx["value"]["meta"], ctx["plan"]["factors"] + + memo: dict[float, tuple[dict, dict] | None] = {} + + def run(n: float) -> tuple[dict, dict] | None: + n = round(n, 4) + if n not in memo: + variant = whatif.design_variant(design, ground, {**assume, "ratio": n}) + # None = 해석 폭 안에서 원지반을 못 만남(자연 비탈보다 완만) + memo[n] = variant and (analyze_station(ctx["cross"], variant, **ctx["kw"]), variant) + return memo[n] + + reached: list[float] = [] # 원지반을 만난 기울기 + + def passes(n: float) -> bool: + done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜) + if done is None: + return False + reached.append(n) + cases = _side_cases(view.station_row(meta, done[0], factors), req.side) + if req.condition: + cases = {k: v for k, v in cases.items() if k == req.condition} + need = (lambda c: c["required"]) if req.target_fs is None else (lambda c: req.target_fs) + return bool(cases) and all( + c["fs"] is not None and need(c) is not None and c["fs"] >= need(c) + for c in cases.values() + ) + + search = None + if req.search: + top = whatif.gentlest_ratio(design, ground, assume, before_ratio) or before_ratio + search = {**whatif.search_ratio(passes, before_ratio, max_ratio=top), "gentlest": top} + # 못 찾으면 원지반을 만난 가장 완만한 기울기로 보여 줌 + assume["ratio"] = search["found"] or max(reached, default=before_ratio) + assume["ratio"] = round(assume["ratio"], 4) + done = run(assume["ratio"]) + if done is None: + raise HTTPException( + status_code=422, + detail=f"가정 비탈(1:{assume['ratio']:g} · 소단 포함)이 해석 폭 안에서 원지반을 못 만납니다" + " — 자연 비탈보다 완만.", + ) + out, variant = done + after = view.station_row(meta, out, factors) + record = { + "type": "slope", + "station_key": station_key, + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "base_fp": row["fp"], + "assume": {**assume, "ratio_before": before_ratio}, + "search": search and {**search, **goal}, + "before": _side_cases(row, req.side), + "after": _side_cases(after, req.side), + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "elapsed_s": round(time.perf_counter() - t0, 2), + } + shown = _section(ctx, variant, after, out) + write_derived(root, KIND_VIEW, key, shown, keep=KEEP) + write_derived(root, KIND, key, record, keep=KEEP) + return {**record, "view": shown} + + +def _material_at(model: dict[str, Any], x: float, z: float, skip: str = "wall") -> str | None: + pt = Point(x, z) + for region in model["regions"]: + if region["material_id"] != skip and Polygon(region["polygon"]).buffer(1e-6).contains(pt): + return region["material_id"] + return None + + +def _surface_z(surface: list, x: float) -> float: + pts = sorted(surface) + for (xa, za), (xb, zb) in zip(pts, pts[1:]): + if xa <= x <= xb: + return za + (zb - za) * ((x - xa) / (xb - xa) if xb > xa else 0.0) + return pts[0][1] if x < pts[0][0] else pts[-1][1] + + +def run_wallcheck(root: Path, station_key: str, req: WallRequest) -> dict[str, Any]: + ctx = _context(root, station_key) + if not ctx["walls"]: + raise HTTPException(status_code=404, detail="그 측점에 벽이 없습니다.") + row = ctx["row"] + seismic = ctx["ground"].get("seismic") or {} + kh = ( + req.kh + if req.kh is not None + else float(seismic.get("kh") or 0.0) * bool(seismic.get("enabled")) + ) + params = {"method": req.method, "water_ratio": req.water_ratio, "kh": kh} + key = derived_key(code_hash(), row["fp"], "wall", params) + cached = read_derived(root, KIND, key) + if cached: + return cached["value"] + kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa", "walls")} + model = section_model_from_b06(ctx["cross"], ctx["design"], **kw) + mats = {m["id"]: m for m in model["materials"]} + roles = {s["side"]: s["role"] for s in row["sides"]} + walls = [] + for wall in ctx["walls"]: + poly = Polygon(wall["polygon"]) + side = _wall_side(wall) + role = roles.get(side, "fill") + outward = 1 if side == "left" else -1 + back = -outward if role == "fill" else outward # 쌓기 = 노면 쪽 흙 · 깎기 = 산 쪽 흙 + x0, z0, x1, z1 = poly.bounds + H = z1 - z0 + heel = x1 if back > 0 else x0 + toe = x0 if back > 0 else x1 + fill_id = _material_at(model, heel + back * 0.3, z0 + 0.5 * H) or "fill" + found_id = _material_at(model, 0.5 * (x0 + x1), z0 - 0.2) or fill_id + backfill, foundation = mats.get(fill_id, mats.get("fill")), mats.get(found_id) + surface = model["surface"] + dz = _surface_z(surface, heel + back * max(H, 1.0)) - _surface_z(surface, heel) + beta = math.degrees(math.atan2(dz, max(H, 1.0))) + beta = min(max(beta, 0.0), float(backfill["phi"]) - 1.0) + common = { + "backfill": backfill, + "foundation": foundation, + "method": req.method, + "beta_deg": beta, + "surcharge_kpa": ctx["kw"]["road_surcharge_kpa"] if role == "fill" else 0.0, + "water_ratio": req.water_ratio, + "wall_gamma": float(WALL_MATERIAL["gamma_t"]), + "embed_m": max(_surface_z(surface, toe - back * 0.05) - z0, 0.0), + } + cases = [wallcheck.check_wall(wall["polygon"], back, **common)] + if kh > 0: + cases.append(wallcheck.check_wall(wall["polygon"], back, kh=kh, **common)) + walls.append( + { + "name": wall.get("name"), + "kind": wall.get("kind"), + "side": side, + "role": role, + "backfill": fill_id, + "foundation": found_id, + "cases": cases, + } + ) + record = { + "type": "wall", + "station_key": station_key, + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "base_fp": row["fp"], + "params": params, + "walls": walls, + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + write_derived(root, KIND, key, record, keep=KEEP) + return record + + +def records(root: Path) -> list[dict[str, Any]]: + """가장 최근 2D 요약 측점에 붙은 가정 · 벽 검토 — (측점 · 종류 · 비탈)마다 가장 최근 하나 · + 누가거리 순.""" + summary, _ = e03._latest_summary(root, "") + fps = {r["fp"] for r in (summary or {}).get("stations") or []} + latest: dict[tuple, dict[str, Any]] = {} + for path in derived_dir(root, KIND).glob("*.json"): + saved = read_derived(root, KIND, path.stem) + value = (saved or {}).get("value") + if not value or value.get("base_fp") not in fps: + continue + slot = (value["station_key"], value["type"], (value.get("assume") or {}).get("side")) + if slot not in latest or value["computed_at"] > latest[slot]["computed_at"]: + latest[slot] = value + return sorted(latest.values(), key=lambda v: (float(v["chainage_m"]), v["type"])) + + +@router.post("/{project_id}/stab/whatif/{station_key}", response_model=None) +async def post_whatif(project_id: UUID, station_key: str, payload: WhatIfRequest) -> dict: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(run_whatif, root, station_key, payload) + + +@router.post("/{project_id}/stab/wallcheck/{station_key}", response_model=None) +async def post_wallcheck(project_id: UUID, station_key: str, payload: WallRequest) -> dict: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(run_wallcheck, root, station_key, payload) + + +@router.get("/{project_id}/stab/whatif", response_model=None) +async def get_whatif(project_id: UUID) -> dict: + root, _ = await e02._project(project_id) + return {"records": await asyncio.to_thread(records, root)} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index c84fc4af9..f60d17cb3 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -5,6 +5,7 @@ * 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프 * 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) · * 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음). + * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). * ========================================================================== */ import "./E03_StabSection_UI_Style.css"; @@ -31,6 +32,13 @@ import { type SectionToggles, } from "./E03_StabSection_UI_Section"; import { openView3d } from "./E03_StabSection_UI_View3D"; +import { + buildReinforce, + type SlopeAsk, + type WallAsk, + type WallRecord, + type WhatIfRecord, +} from "./E03_StabSection_UI_Reinforce"; import type { Analysis, Condition, @@ -101,6 +109,10 @@ export function renderE03StabSection(root: HTMLElement): void { let side: string | null = null; let picked: number | null = null; const toggles: SectionToggles = { map: true, centers: false, candidates: true }; + let mode: "result" | "reinforce" = "result"; + let busy = false; + const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 + const wallRecs = new Map(); let repCache: Record = JSON.parse( sessionStorage.getItem(repKey(pid)) ?? "{}", ); @@ -292,7 +304,21 @@ export function renderE03StabSection(root: HTMLElement): void { (v) => ((toggles.candidates = v), paintSection()), ), ); - toolbar.append(title, sideField.root, flags); + const modeField = createSelectField({ + options: [ + { value: "result", text: t("E03_Tab_Result") }, + { value: "reinforce", text: t("E03_Tab_Reinforce") }, + ], + value: mode, + compact: true, + ariaLabel: t("E03_Tab"), + onChange: (v) => { + mode = v as typeof mode; + paintSection(); + }, + }); + modeField.select.dataset.role = "mode"; + toolbar.append(title, sideField.root, modeField.root, flags); const draw = { view, analysis, toggles, picked }; canvas.render(draw); legendHost.append(buildLegends(draw)); @@ -307,6 +333,25 @@ export function renderE03StabSection(root: HTMLElement): void { ); if (view.regions.some((r) => r.material_id === WALL_ID)) legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); + const row = currentRow(); + if (mode === "reinforce" && row) { + if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable) + queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 })); + resultHost.append( + buildReinforce({ + row, + condition, + side, + editable: !!result?.editable, + busy, + slope: slopeRecs.get(row.key), + wall: wallRecs.get(row.key), + runSlope: (ask) => void runSlope(row.key, ask), + runWall: (ask) => void runWall(row.key, ask), + }), + ); + return; + } resultHost.append( buildResult(view, analysis, picked, (i) => { picked = i; @@ -315,6 +360,32 @@ export function renderE03StabSection(root: HTMLElement): void { ); }; + /** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */ + async function reinforce( + path: string, + body: unknown, + done: (value: T) => void, + ): Promise { + busy = true; + paintSection(); + try { + done(await api(path, { method: "POST", body: JSON.stringify(body) })); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + busy = false; + paintSection(); + } + } + const runSlope = (key: string, ask: SlopeAsk): Promise => + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => + slopeRecs.set(key, rec), + ); + const runWall = (key: string, ask: WallAsk): Promise => + reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => + wallRecs.set(key, rec), + ); + const paintPlan = (): void => { planHost.replaceChildren( result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), diff --git a/E03_StabSection/E03_StabSection_UI_Reinforce.ts b/E03_StabSection/E03_StabSection_UI_Reinforce.ts new file mode 100644 index 000000000..0b30092bd --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Reinforce.ts @@ -0,0 +1,314 @@ +/* ============================================================================= + * E03_StabSection_UI_Reinforce.ts + * E03 「보강 검토」 탭(PLAN 536-3) — 고른 측점 비탈 가정(1:n 완화 · 소단 폭 · 높이 · 최소 완화 찾기) + * → 서버 재해석(POST …/stab/whatif/{측점}) · 전후 안전율 · 판정 표 · 후 단면 그림 · + * 벽 있는 측점 = 벽 외적 안정 표(POST …/stab/wallcheck/{측점} · 활동 · 전도 · 지지력). + * 가정은 형상만 바꾼 따로 저장본(원본 결과 그대로) · 계산은 서버. + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSectionCanvas } from "./E03_StabSection_UI_Section"; +import { verdictChip } from "./E03_StabSection_UI_Result"; +import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; + +export interface WhatIfRecord { + type: "slope"; + station_key: string; + assume: { + side: string; + role: "cut" | "fill"; + ratio: number; + ratio_before: number; + bench_w: number; + bench_h: number; + }; + search: { + found: number | null; + gentlest: number; + tried: [number, boolean][]; + condition: Condition | null; + } | null; + before: Partial>; + after: Partial>; + elapsed_s: number; + view: SectionView; +} + +type Check = "sliding" | "overturning" | "bearing"; +interface WallCase { + case: "normal" | "seismic"; + fs: Record; + required: Record; + ok: Record; + coefficients: { ka: number; kae: number | null }; + base: { e: number; q_max: number; q_ult: number }; + geometry: { H: number; B: number }; +} +export interface WallRecord { + type: "wall"; + params: { method: string; water_ratio: number; kh: number }; + walls: { name: string | null; side: string; role: string; cases: WallCase[] }[]; +} + +export interface SlopeAsk { + side: string; + ratio: number | null; + bench_w: number; + bench_h: number; + search: boolean; + /** 최소 완화 찾기 목표 조건(그 조건 기준안전율) */ + condition: Condition | null; +} +export interface WallAsk { + method: string; + water_ratio: number; +} + +export interface ReinforceOptions { + row: StationRow; + condition: Condition; + side: string | null; + editable: boolean; + busy: boolean; + slope: WhatIfRecord | undefined; + wall: WallRecord | undefined; + runSlope: (ask: SlopeAsk) => void; + runWall: (ask: WallAsk) => void; +} + +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); +const CONDS: Condition[] = ["dry", "wet", "seismic"]; +const CHECKS: Check[] = ["sliding", "overturning", "bearing"]; + +function num(input: HTMLInputElement): number | null { + const v = Number(input.value); + return input.value.trim() === "" || !Number.isFinite(v) ? null : v; +} + +function slopeTable(rec: WhatIfRecord): HTMLElement { + const table = el("table", { className: "e03-table", attrs: { "data-role": "whatif-table" } }); + const head = ["E03_Condition", "E03_Rf_Before", "E03_Rf_After", "E03_Required"] as LocaleKey[]; + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + ...head.map((k, i) => el("th", { className: i ? "is-num" : "", text: t(k) })), + el("th", { text: t("E03_Rf_VerdictBefore") }), + el("th", { text: t("E03_Rf_VerdictAfter") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + for (const cond of CONDS) { + const a = rec.after[cond]; + if (!a) continue; + const b = rec.before[cond]; + const tr = el("tr", { attrs: { "data-cond": cond } }); + tr.append( + el("td", { text: t(`E03_Cond_${cond}` as LocaleKey) }), + el("td", { className: "is-num", text: fmt(b?.fs, 3) }), + el("td", { className: "is-num", text: fmt(a.fs, 3), attrs: { "data-role": "fs-after" } }), + el("td", { className: "is-num", text: fmt(a.required) }), + el("td", { children: [verdictChip(b?.ok ?? null)] }), + el("td", { children: [verdictChip(a.ok)] }), + ); + body.append(tr); + } + table.append(body); + return table; +} + +function assumeText(rec: WhatIfRecord): string { + const a = rec.assume; + let text = `1:${a.ratio_before} → 1:${a.ratio}`; + if (a.bench_w && a.bench_h) text += ` · ${t("E03_Rf_Bench")} ${a.bench_w} m / ${a.bench_h} m`; + if (rec.search) + text += rec.search.found + ? ` · ${t("E03_Rf_Found")} (${rec.search.tried.length}${t("E03_Iter")})` + : ` · ${t("E03_Rf_NotFound").replace("{n}", String(rec.search.gentlest))}`; + return `${text} · ${rec.elapsed_s} s`; +} + +function wallTable(rec: WallRecord): HTMLElement { + const box = el("div", { attrs: { "data-role": "wall-table" } }); + for (const wall of rec.walls) { + for (const c of wall.cases) { + box.append( + el("p", { + className: "e03-muted", + text: + `${wall.name ?? t("E03_Rf_Wall")} · ${t(`E03_Rf_Case_${c.case}` as LocaleKey)} · ` + + `H ${fmt(c.geometry.H)} m · B ${fmt(c.geometry.B)} m · Ka ${fmt(c.coefficients.ka, 3)}` + + (c.coefficients.kae ? ` · KAE ${fmt(c.coefficients.kae, 3)}` : "") + + ` · e ${fmt(c.base.e)} m · q_max ${fmt(c.base.q_max, 1)} kPa`, + }), + ); + const table = el("table", { className: "e03-table" }); + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + el("th", { text: t("E03_Rf_Check") }), + el("th", { className: "is-num", text: t("E03_Rf_Fs") }), + el("th", { className: "is-num", text: t("E03_Required") }), + el("th", { text: t("E03_Verdict") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + for (const k of CHECKS) + body.append( + el("tr", { + attrs: { "data-check": k, "data-case": c.case }, + children: [ + el("td", { text: t(`E03_Rf_${k}` as LocaleKey) }), + el("td", { className: "is-num", text: fmt(c.fs[k]) }), + el("td", { className: "is-num", text: fmt(c.required[k], 1) }), + el("td", { children: [verdictChip(c.ok[k])] }), + ], + }), + ); + table.append(body); + box.append(table); + } + } + return box; +} + +export function buildReinforce(o: ReinforceOptions): HTMLElement { + const host = el("div", { className: "e03-card", attrs: { "data-role": "reinforce" } }); + const sides = o.row.sides; + const sideField = createSelectField({ + label: t("E03_Side"), + options: sides.map((s) => ({ + value: s.side, + text: `${t(s.side === "left" ? "E03_Left" : "E03_Right")} · ${t(s.role === "cut" ? "E03_Cut" : "E03_Fill")}`, + })), + value: o.slope?.assume.side ?? o.side ?? undefined, + compact: true, + }); + const ratio = createInputField({ + label: t("E03_Rf_Ratio"), + type: "number", + min: 0.1, + max: 4, + compact: true, + value: o.slope ? String(o.slope.assume.ratio) : "", + placeholder: t("E03_Rf_RatioNow"), + }); + const benchW = createInputField({ + label: t("E03_Rf_BenchW"), + type: "number", + min: 0, + compact: true, + value: o.slope?.assume.bench_w ? String(o.slope.assume.bench_w) : "", + }); + const benchH = createInputField({ + label: t("E03_Rf_BenchH"), + type: "number", + min: 0, + compact: true, + value: o.slope?.assume.bench_h ? String(o.slope.assume.bench_h) : "", + }); + for (const f of [ratio, benchW, benchH]) f.input.step = "0.05"; + const ask = (search: boolean): SlopeAsk => ({ + side: sideField.select.value, + ratio: search ? null : num(ratio.input), + bench_w: num(benchW.input) ?? 0, + bench_h: num(benchH.input) ?? 0, + search, + condition: search ? o.condition : null, + }); + const run = createButton({ + label: o.busy ? t("E03_Rf_Busy") : t("E03_Rf_Run"), + variant: "filled", + onClick: () => o.runSlope(ask(false)), + }); + const find = createButton({ + label: t("E03_Rf_Search"), + variant: "ghost", + onClick: () => o.runSlope(ask(true)), + }); + run.disabled = find.disabled = o.busy || !o.editable; + run.dataset.role = "whatif-run"; + find.dataset.role = "whatif-search"; + const fields = el("div", { className: "e03-panel__row" }); + fields.append(sideField.root, ratio.root, benchW.root, benchH.root); + const buttons = el("div", { className: "e03-panel__row" }); + buttons.append(find, run); + host.append( + el("h3", { className: "e03-card__title", text: t("E03_Rf_Title") }), + el("p", { className: "e03-muted", text: t("E03_Rf_Hint") }), + el("p", { + className: "e03-muted", + text: t("E03_Rf_Goal").replace("{c}", t(`E03_Cond_${o.condition}` as LocaleKey)), + }), + fields, + buttons, + ); + if (o.slope) { + host.append( + el("p", { + className: "e03-muted", + text: assumeText(o.slope), + attrs: { "data-role": "assume" }, + }), + slopeTable(o.slope), + el("h4", { className: "e03-card__title", text: t("E03_Rf_AfterSection") }), + ); + const canvas = createSectionCanvas(() => undefined); + canvas.root.style.height = "260px"; + canvas.root.style.flex = "none"; + canvas.root.dataset.role = "whatif-section"; + host.append(canvas.root); + const analyses = o.slope.view.analyses.filter((a) => a.condition === o.condition); + const analysis = analyses.find((a) => a.side === o.slope!.assume.side) ?? analyses[0] ?? null; + const draw = { + view: o.slope.view, + analysis, + toggles: { map: false, centers: false, candidates: false }, + picked: null, + }; + requestAnimationFrame(() => canvas.render(draw)); + } + if (o.row.walls) { + const method = createSelectField({ + label: t("E03_Rf_Method"), + options: [ + { value: "coulomb", text: "Coulomb" }, + { value: "rankine", text: "Rankine" }, + ], + value: o.wall?.params.method, + compact: true, + }); + const water = createInputField({ + label: t("E03_Rf_Water"), + type: "number", + min: 0, + max: 1, + compact: true, + value: String(o.wall?.params.water_ratio ?? 0), + }); + water.input.step = "0.1"; + const check = createButton({ + label: t("E03_Rf_WallRun"), + variant: "ghost", + onClick: () => o.runWall({ method: method.select.value, water_ratio: num(water.input) ?? 0 }), + }); + check.disabled = o.busy || !o.editable; + check.dataset.role = "wall-run"; + const row = el("div", { className: "e03-panel__row" }); + row.append(method.root, water.root, check); + host.append(el("h3", { className: "e03-card__title", text: t("E03_Rf_WallTitle") }), row); + if (o.wall) host.append(wallTable(o.wall)); + } + return host; +} diff --git a/E03_StabSection/E03_StabSection_WallCheck.py b/E03_StabSection/E03_StabSection_WallCheck.py new file mode 100644 index 000000000..e02dc0ce9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_WallCheck.py @@ -0,0 +1,220 @@ +"""E03 벽(옹벽 · 석축) 외적 안정 — 활동 · 전도 · 지지력 (요구 345 · 351 · PLAN 536-3). + +storage 를 모르는 순수 함수. 벽 폴리곤(2D 모형 `walls` · x = B06 offset · z = 표고) + 배면 · 기초 물성 +→ 하중 · 안전율 · 판정. 기준 = KDS 11 80 05(지식DB `구조물_안정계산.md` §2 · `original/안정성평가/04`). + +- 토압 = Coulomb(벽 배면 = 뒷굽 바닥 ~ 배면 꼭대기 가상면 · 벽마찰 δ) 또는 Rankine(뒷굽 연직 가상면 · + δ = β) · 지진 시 = Mononobe-Okabe(정적 H/3 + 증분 0.6H · Seed-Whitman) + 벽 관성 kh·W. +- 가상면과 벽 사이 흙 = 벽 무게에 더함 · 배면 점착 = 0(보수 쪽). +- 상재하중 q = 배면 등분포(노면 하중) · 수압 = 배면 수위 hw(바닥부터) 정수압 + 바닥 삼각 양압력 · + 수위 아래 토압 = 수중 단위중량. +- 활동 = (V·μ + c_b·B) / ΣH(수동토압 빼고) · 전도 = 앞굽 기준 저항 / 전도 모멘트 · + 지지력 = Meyerhof 극한(유효폭 B' · 경사 계수) / 최대 지반반력(사다리꼴 · 삼각). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from shapely.geometry import LineString, Polygon, box +from shapely.ops import unary_union + +GAMMA_W = 9.81 +#: KDS 11 80 05 기준안전율 — 평상시 · 지진 시(활동은 수동토압 뺀 값) +REQUIRED = { + "normal": {"sliding": 1.5, "overturning": 2.0, "bearing": 3.0}, + "seismic": {"sliding": 1.2, "overturning": 1.5, "bearing": 2.0}, +} +#: 벽 단위중량(kN/m³) — 콘크리트 · 찰쌓기 23 · 메쌓기(공극) 20 +WALL_GAMMA = {"default": 23.0, "dry_masonry": 20.0} +_N = 400 # 토압 적분 칸 + + +def ka_coulomb(phi: float, delta: float, theta: float, beta: float, psi: float = 0.0) -> float: + """Coulomb 주동토압계수(ψ > 0 = Mononobe-Okabe KAE · kv = 0). 각 = 도. + + θ = 배면이 연직에서 앞쪽(벽 쪽)으로 누운 각(쐐기가 커지는 쪽 +) · β = 배면 흙 경사 · δ = 벽마찰.""" + f, d, t, b, p = (math.radians(v) for v in (phi, delta, theta, beta, psi)) + under = math.sin(f + d) * math.sin(f - b - p) / (math.cos(d + t + p) * math.cos(b - t)) + root = math.sqrt(max(under, 0.0)) + return math.cos(f - t - p) ** 2 / ( + math.cos(p) * math.cos(t) ** 2 * math.cos(d + t + p) * (1.0 + root) ** 2 + ) + + +def ka_rankine(phi: float, beta: float = 0.0) -> float: + """Rankine 주동토압계수(경사 배면 β · 합력은 β 로 기욺).""" + f, b = math.radians(phi), math.radians(beta) + root = math.sqrt(max(math.cos(b) ** 2 - math.cos(f) ** 2, 0.0)) + return math.cos(b) * (math.cos(b) - root) / (math.cos(b) + root) + + +def bearing_factors(phi: float) -> tuple[float, float, float]: + """Meyerhof 지지력 계수(Nc · Nq · Nγ) — φ 도.""" + f = math.radians(phi) + if f < 1e-9: + return 5.14, 1.0, 0.0 + nq = math.exp(math.pi * math.tan(f)) * math.tan(math.pi / 4 + f / 2) ** 2 + return (nq - 1.0) / math.tan(f), nq, (nq - 1.0) * math.tan(1.4 * f) + + +def _pressure( + ka: float, H: float, gamma: float, gamma_sat: float, q: float, hw: float +) -> tuple[float, float]: + """연직 높이 H 가상면 토압 합력 · 바닥에서 작용 높이 — σ' = 수위 위 γ · 아래 γ' 누적 + q.""" + z = np.linspace(0.0, H, _N + 1) # 꼭대기에서 아래로 + dry = np.minimum(z, H - hw) + wet = np.maximum(z - (H - hw), 0.0) + p = ka * (gamma * dry + (gamma_sat - GAMMA_W) * wet + q) + total = float(np.trapz(p, z)) + arm = float(np.trapz(p * (H - z), z)) / total if total > 0 else H / 3 + return total, arm + + +def check_wall( + polygon: list, + back: int, + *, + backfill: dict[str, float], + foundation: dict[str, float], + method: str = "coulomb", + delta_ratio: float = 2.0 / 3.0, + beta_deg: float = 0.0, + surcharge_kpa: float = 10.0, + water_ratio: float = 0.0, + kh: float = 0.0, + wall_gamma: float = WALL_GAMMA["default"], + base_friction: float | None = None, + base_adhesion: float = 0.0, + embed_m: float = 0.0, +) -> dict[str, Any]: + """벽 하나 외적 안정. back = 배면(흙) 쪽 x 방향(+1 · −1). 물성 = {gamma_t, gamma_sat, c, phi}. + + kh > 0 이면 지진 시(기준 `REQUIRED["seismic"]`) · 아니면 평상시. 돌려줌 = 하중 · 안전율 · 판정.""" + wall = Polygon(polygon) + xs = np.array([p[0] for p in polygon], dtype=float) + zs = np.array([p[1] for p in polygon], dtype=float) + zb, zt = float(zs.min()), float(zs.max()) + H = zt - zb + base = xs[zs <= zb + 1e-3] + heel = float(base.max() if back > 0 else base.min()) + toe = float(base.min() if back > 0 else base.max()) + B = abs(heel - toe) + phi, gamma = float(backfill["phi"]), float(backfill["gamma_t"]) + gamma_sat = float(backfill.get("gamma_sat") or gamma) + hw = min(max(water_ratio, 0.0), 1.0) * H + + if method == "rankine": + x_top, theta, delta = heel, 0.0, beta_deg + else: + top = xs[zs >= zt - 1e-3] + x_top = float(top.max() if back > 0 else top.min()) # 배면 꼭대기 모서리 + theta = math.degrees(math.atan2(-(x_top - heel) * back, H)) + delta = delta_ratio * phi + # 가상면과 벽 사이 흙 — 벽 쪽 반평면 ∩ 벽 상자 − 벽 · 가상면에 붙은 조각만(앞면 앞 흙 빼고) + reach = 10.0 * (H + B + 1.0) + side = [(heel, zb), (x_top, zt), (x_top - back * reach, zt), (heel - back * reach, zb)] + plane = LineString([(heel, zb), (x_top, zt)]) + rest = Polygon(side).intersection(box(*wall.bounds)).difference(wall) + parts = [g for g in getattr(rest, "geoms", [rest]) if isinstance(g, Polygon)] + soil = unary_union([g for g in parts if g.area > 1e-9 and g.distance(plane) < 1e-6]) + soil_w = soil.area * gamma + soil_x = soil.centroid.x if soil_w > 0 else heel + soil_z = soil.centroid.y if soil_w > 0 else zb + + ka = ( + ka_rankine(phi, beta_deg) + if method == "rankine" + else ka_coulomb(phi, delta, theta, beta_deg) + ) + q_eff = surcharge_kpa * ( + 1.0 + if method == "rankine" + else math.cos(math.radians(theta)) / math.cos(math.radians(theta - beta_deg)) + ) + Pa, ya = _pressure(ka, H, gamma, gamma_sat, q_eff, hw) + loads = [("earth", Pa, ya)] + kae = None + if kh > 0: + psi = math.degrees(math.atan(kh)) + kae = ka_coulomb(phi, delta, theta, beta_deg, psi) + Pae, _ = _pressure(kae, H, gamma, gamma_sat, q_eff, hw) + loads.append(("seismic_earth", max(Pae - Pa, 0.0), 0.6 * H)) + incline = math.radians(delta + theta) + + def plane_x(y: float) -> float: # 가상면 위 높이 y 자리 x + return heel + (x_top - heel) * (y / H if H > 0 else 0.0) + + def arm(x: float) -> float: # 앞굽에서 배면 쪽 수평 팔 길이 + return (x - toe) * back + + W = wall.area * wall_gamma + xw, zw = wall.centroid.x, wall.centroid.y + Ph = sum(P for _, P, _ in loads) * math.cos(incline) + Pv = sum(P for _, P, _ in loads) * math.sin(incline) + m_res = W * arm(xw) + soil_w * arm(soil_x) + m_over = 0.0 + for _, P, y in loads: + m_res += P * math.sin(incline) * arm(plane_x(y)) + m_over += P * math.cos(incline) * y + Pw = 0.5 * GAMMA_W * hw**2 + U = 0.5 * GAMMA_W * hw * B + m_over += Pw * hw / 3.0 + U * (2.0 * B / 3.0) + inertia = kh * (W + soil_w) + m_over += kh * W * (zw - zb) + kh * soil_w * (soil_z - zb) + H_total = Ph + Pw + inertia + V = W + soil_w + Pv - U + + phi_f = float(foundation["phi"]) + mu = base_friction if base_friction is not None else math.tan(math.radians(2.0 / 3.0 * phi_f)) + sliding = (V * mu + base_adhesion * B) / H_total if H_total > 0 else math.inf + overturning = m_res / m_over if m_over > 0 else math.inf + xbar = (m_res - m_over) / V if V > 0 else -math.inf + e = B / 2.0 - xbar + if xbar <= 0: + q_max, b_eff = math.inf, 0.0 + elif abs(e) <= B / 6.0: + q_max, b_eff = V / B * (1.0 + 6.0 * abs(e) / B), B - 2.0 * abs(e) + else: + q_max, b_eff = 2.0 * V / (3.0 * min(xbar, B - xbar)), B - 2.0 * abs(e) + nc, nq, ng = bearing_factors(phi_f) + alpha = math.degrees(math.atan2(H_total, V)) if V > 0 else 90.0 + i_cq = (1.0 - alpha / 90.0) ** 2 + i_g = (1.0 - alpha / phi_f) ** 2 if phi_f > 0 and alpha < phi_f else 0.0 + gf = float(foundation["gamma_t"]) + q_ult = ( + float(foundation.get("c") or 0.0) * nc * i_cq + + gf * max(embed_m, 0.0) * nq * i_cq + + 0.5 * gf * max(b_eff, 0.0) * ng * i_g + ) + bearing = q_ult / q_max if q_max > 0 and math.isfinite(q_max) else 0.0 + case = "seismic" if kh > 0 else "normal" + fs = {"sliding": sliding, "overturning": overturning, "bearing": bearing} + need = REQUIRED[case] + return { + "case": case, + "method": method, + "geometry": {"H": H, "B": B, "toe_x": toe, "heel_x": heel, "theta_deg": theta}, + "coefficients": {"ka": ka, "kae": kae, "delta_deg": delta, "beta_deg": beta_deg}, + "loads": { + "W": W, + "W_soil": soil_w, + "Pa": Pa, + "Pa_arm": ya, + "Ph": Ph, + "Pv": Pv, + "Pw": Pw, + "U": U, + "inertia": inertia, + "V": V, + "H": H_total, + "M_resist": m_res, + "M_overturn": m_over, + }, + "base": {"e": e, "q_max": q_max, "q_ult": q_ult, "B_eff": b_eff, "mu": mu, "alpha": alpha}, + "fs": fs, + "required": dict(need), + "ok": {k: bool(fs[k] >= need[k]) for k in fs}, + } diff --git a/E03_StabSection/E03_StabSection_WhatIf.py b/E03_StabSection/E03_StabSection_WhatIf.py new file mode 100644 index 000000000..9593dbf87 --- /dev/null +++ b/E03_StabSection/E03_StabSection_WhatIf.py @@ -0,0 +1,199 @@ +"""E03 보강 가정 — 비탈 기울기 완화 · 소단 덧붙임 → 설계선 다시 그림 · 최소 완화 찾기 (PLAN 536-3). + +storage 를 모르는 순수 함수. 가정 = 모형 형상만 바꿈(설계 · 임도 쪽으로 안 돌려 씀 · 요구 345 E). +- `reshape_side` = 한 쪽 비탈을 비탈 시작점에서 1:n(+ 높이 h 마다 폭 w 소단)으로 원지반까지 다시 그림 · + 안쪽(노면 · 측구)은 그대로 · 2단(암 · 토사) 경사는 한 경사로 갈음. +- `slope_start` = 비탈 시작점 — 깎기 = 측구 바깥(`cut_slope_segments` 시작) · 쌓기 = 노면 끝. +- `search_ratio` = 목표 안전율을 맞추는 가장 급한 1:n(이분 · `step` 단위 올림) — 해석은 부르는 쪽 함수. +좌표 = B06 offset(좌 +) · 표고. +""" + +from __future__ import annotations + +import math +from typing import Any, Callable + +import numpy as np + +#: 최소 완화 찾기 — 가장 완만한 기울기 · 찾는 단위 +MAX_RATIO = 4.0 +RATIO_STEP = 0.05 + + +def current_ratio(design: dict[str, Any], side: str, role: str) -> float: + """지금 설계 기울기 1:n 의 n — 깎기 = 그 쪽 첫 구간 · 쌓기 = 쌓기 기울기.""" + if role == "fill": + return float(design.get("fill_slope_ratio") or 1.5) + segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] + if segs: + return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["ratio"]) + return float(design.get("cut_slope_ratio") or 1.0) + + +def slope_start(design: dict[str, Any], side: str, role: str) -> float: + """비탈 시작 offset.""" + edge = float(((design.get("road_edges") or {}).get(side) or {}).get("offset_m", 0.0)) + if role == "cut": + segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] + if segs: + return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["start_offset_m"]) + return edge + + +def _profile( + x0: float, z0: float, sign: float, up: float, ratio: float, bench_w: float, bench_h: float +) -> list[tuple[float, float]]: + """비탈 꼭짓점(시작 → 바깥) — 원지반을 넉넉히 지나도록 길게(뒤에서 자름).""" + pts = [(x0, z0)] + x, z = x0, z0 + while abs(x - x0) < 200.0: + rise = bench_h if bench_w > 0 and bench_h > 0 else 200.0 + x, z = x + sign * rise * ratio, z + up * rise + pts.append((x, z)) + if bench_w > 0 and bench_h > 0: + x += sign * bench_w + pts.append((x, z)) + return pts + + +def _daylight( + pts: list[tuple[float, float]], ground: list, sign: float, up: float +) -> list[tuple[float, float]] | None: + """비탈이 원지반과 처음 만나는 점까지 — 못 만나면 None(지형 밖). 시작부터 원지반 밖이면 시작점만.""" + gx = np.array([p[0] for p in ground], dtype=float) + gz = np.array([p[1] for p in ground], dtype=float) + out = [pts[0]] + for (xa, za), (xb, zb) in zip(pts, pts[1:]): + inside = gx[(gx > min(xa, xb)) & (gx < max(xa, xb))] + xs = np.unique(np.r_[xa, xb, inside]) + xs = xs[np.argsort(sign * xs)] + zl = za + (xs - xa) / (xb - xa) * (zb - za) if xb != xa else np.full(len(xs), zb) + f = up * (zl - np.interp(xs, gx, gz)) # ≥ 0 = 원지반 밖으로 나옴 + if len(out) == 1 and f[0] >= 0: + return out + for k in range(1, len(xs)): + if not gx[0] <= xs[k] <= gx[-1]: + return None + if f[k] >= 0: + s = f[k - 1] / (f[k - 1] - f[k]) + x = float(xs[k - 1] + s * (xs[k] - xs[k - 1])) + return out + [(x, float(zl[k - 1] + s * (zl[k] - zl[k - 1])))] + out.append((xb, zb)) + return None + + +def reshape_side( + design_line: list, + ground: list, + *, + side: str, + role: str, + start_x: float, + ratio: float, + bench_w: float = 0.0, + bench_h: float = 0.0, +) -> list[list[float]] | None: + """한 쪽 비탈만 다시 그린 설계선([[offset, z] …]) — 원지반을 못 만나면 None.""" + line = sorted((float(x), float(z)) for x, z in design_line) + sign = 1.0 if side == "left" else -1.0 + up = 1.0 if role == "cut" else -1.0 + lx = np.array([p[0] for p in line]) + lz = np.array([p[1] for p in line]) + z0 = float(np.interp(start_x, lx, lz)) + inner = [p for p in line if sign * p[0] < sign * start_x - 1e-9] + slope = _daylight(_profile(start_x, z0, sign, up, ratio, bench_w, bench_h), ground, sign, up) + if slope is None: + return None + return [list(p) for p in sorted(inner + slope)] + + +def design_variant( + design: dict[str, Any], ground: list, assume: dict[str, Any] +) -> dict[str, Any] | None: + """가정 하나(side · role · ratio · bench_w · bench_h) → 설계선만 바꾼 설계 사본(None = 원지반 못 만남).""" + side, role = assume["side"], assume["role"] + pts = [(p["offset_m"], p["elevation_m"]) for p in design.get("design_line") or []] + line = reshape_side( + pts, + ground, + side=side, + role=role, + start_x=slope_start(design, side, role), + ratio=float(assume["ratio"]), + bench_w=float(assume.get("bench_w") or 0.0), + bench_h=float(assume.get("bench_h") or 0.0), + ) + if line is None: + return None + return {**design, "design_line": [{"offset_m": x, "elevation_m": z} for x, z in line]} + + +def gentlest_ratio( + design: dict[str, Any], + ground: list, + assume: dict[str, Any], + start: float, + step: float = RATIO_STEP, +) -> float | None: + """원지반을 만나는 가장 완만한 n(start ~ MAX_RATIO · step 단위 · 형상만 · 이분) — 없으면 None.""" + + def meets(n: float) -> bool: + return design_variant(design, ground, {**assume, "ratio": n}) is not None + + lo = math.ceil(start / step - 1e-9) * step + hi = math.floor(MAX_RATIO / step + 1e-9) * step + if not meets(lo): + return None + if meets(hi): + return round(hi, 4) + while hi - lo > step + 1e-9: # lo = 만남 · hi = 못 만남 + mid = round(round((lo + hi) / 2 / step) * step, 6) + if mid in (lo, hi): + break + lo, hi = (mid, hi) if meets(mid) else (lo, mid) + return round(lo, 4) + + +def search_ratio( + passes: Callable[[float], bool], + start: float, + *, + max_ratio: float = MAX_RATIO, + step: float = RATIO_STEP, + coarse: int = 5, +) -> dict[str, Any]: + """목표를 맞추는 가장 급한 n(start ≤ n ≤ max_ratio · step 단위). passes(n) = 해석 · 판정. + + 완만할수록 늘 안전하지 않음(급한 자연 비탈 = 완화가 깎기 높이를 키움) — 성긴 걸음(최대 `coarse` 칸)으로 + 처음 맞는 칸을 찾고 그 앞 칸 사이를 이분으로 좁힘 · 끝까지 못 맞추면 found None.""" + tried: list[tuple[float, bool]] = [] + + def test(n: float) -> bool: + ok = passes(n) + tried.append((round(n, 4), ok)) + return ok + + def snap(n: float) -> float: + return round(round(n / step) * step, 6) + + lo = snap(math.ceil(start / step - 1e-9) * step) + top = snap(math.floor(max_ratio / step + 1e-9) * step) + if test(lo): + return {"found": lo, "tried": tried} + gap = max(2 * step, snap((top - lo) / coarse)) + hi = None + n = lo + while n < top - 1e-9: + n = min(snap(n + gap), top) + if test(n): + hi = n + break + lo = n + if hi is None: + return {"found": None, "tried": tried} + while hi - lo > step + 1e-9: # lo = 못 맞춤 · hi = 맞춤 + mid = snap((lo + hi) / 2) + if mid in (lo, hi): + break + lo, hi = (lo, mid) if test(mid) else (mid, hi) + return {"found": round(hi, 4), "tried": tried} diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index d0be14542..9529db131 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -2,12 +2,13 @@ 입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 `stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) · E07 광역 저장본(있으면) · +E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) · E01 복사본(측점 · 횡단 · 노선). 양식 = `resources/master_template/report/stab_report.json` 칸 자리 = 같은 폴더 `stab_report_slots.md`. 몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 · -프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7) +프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 (인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). @@ -36,13 +37,14 @@ from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary from E04_Stab3D import E04_Stab3D_Run as run3d from E05_StabReport import E05_StabReport_Area as area from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport import E05_StabReport_Reinforce as reinforce ROOT = Path(__file__).resolve().parents[1] FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json" STAGE = "E05_StabReport" REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") -PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7") +PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7", "s8") BLOCK = 28 # 대표단면 한 쪽 = 28 행 REPRESENTATIVE = Path("E03_StabSection") / "representative.json" BAND_KEY = "fig/3d_band.png" @@ -171,6 +173,7 @@ def analysis_fingerprint(project_root: Path) -> str: if folder.is_dir() else [], "area": area.stamp(project_root), + "reinforce": reinforce.stamp(project_root), "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), } text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) @@ -220,6 +223,7 @@ def gather(project_root: Path) -> dict[str, Any]: "ground": ground, "sections3d": sorted(sections3d, key=lambda s: s["start_m"]), "area": area.read(project_root), + "reinforce": reinforce.read(project_root), "fingerprint": analysis_fingerprint(project_root), } @@ -620,6 +624,10 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st jobs = fill_sections(by["s5"], inputs) jobs.append(fill_3d(by["s6"], inputs)) area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) + if inputs.get("reinforce"): + reinforce.fill_reinforce(by["s8"], inputs["reinforce"], _labels(inputs["stations"])) + else: # 보강 검토가 없으면 시트를 뺌 + book["시트"] = [s for s in book["시트"] if s["id"] != "s8"] return jobs diff --git a/E05_StabReport/E05_StabReport_Reinforce.py b/E05_StabReport/E05_StabReport_Reinforce.py new file mode 100644 index 000000000..93f58008d --- /dev/null +++ b/E05_StabReport/E05_StabReport_Reinforce.py @@ -0,0 +1,135 @@ +"""E05 보고서 s8 보강검토 — E03 보강 검토 저장본(비탈 완화 · 소단 가정 · 벽 외적 안정) → 시트 (PLAN 536-3). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s8. +저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +from E03_StabSection.E03_StabSection_Router_WhatIf import records + +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SIDE_NAMES = {"left": "좌", "right": "우"} +ROLE_NAMES = {"cut": "깎기", "fill": "쌓기"} +CASE_NAMES = {"normal": "평상시", "seismic": "지진 시"} +CHECKS = ("sliding", "overturning", "bearing") +ST_NG, ST_OK = 10, 11 + + +def read(project_root: Path) -> list[dict[str, Any]]: + return records(Path(project_root)) + + +def stamp(project_root: Path) -> str: + """해석 지문용 — 저장본 목록 내용.""" + body = json.dumps(read(project_root), ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(body.encode("utf-8")).hexdigest() + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + cell = dict(sheet["칸"].get(a1) or {}) + cell.pop("식", None) + if value is None: + cell.pop("값", None) + else: + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + sheet["칸"][a1] = cell + + +def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: + if ok is None: + _set(sheet, a1, "-") + else: + _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) + + +def _assume_text(rec: dict[str, Any]) -> str: + a = rec["assume"] + text = f"1:{a['ratio_before']:g} → 1:{a['ratio']:g}" + if a.get("bench_w") and a.get("bench_h"): + text += f" · 소단 폭 {a['bench_w']:g} m / 높이 {a['bench_h']:g} m 마다" + search = rec.get("search") + if search: + text += " · 최소 완화" if search.get("found") else " · 최소 완화 못 찾음(끝값)" + return text + + +def fill_reinforce(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: dict) -> None: + """s8 — 1. 가정 줄(조건마다 한 줄 · 6 행 서식) · 2. 벽 줄(경우마다 한 줄 · 10 행 서식).""" + tpl = { + r: {c: copy.deepcopy(sheet["칸"].get(f"{c}{r}") or {}) for c in "ABCDEFGHI"} + for r in (8, 9, 10) + } + row6 = {c: copy.deepcopy(sheet["칸"].get(f"{c}6") or {}) for c in "ABCDEFGHI"} + for a1 in [k for k in sheet["칸"] if int("".join(ch for ch in k if ch.isdigit())) >= 6]: + del sheet["칸"][a1] + sheet["병합"] = [ + m for m in sheet["병합"] if int("".join(ch for ch in m.split(":")[0] if ch.isdigit())) < 6 + ] + sheet["행"] = {k: v for k, v in sheet["행"].items() if int(k) < 6} + + def put_row(r: int, styles: dict[str, dict]) -> None: + for c, cell in styles.items(): + sheet["칸"][f"{c}{r}"] = copy.deepcopy(cell) + + def name(rec: dict[str, Any]) -> str: + ch = float(rec["chainage_m"]) + return rec.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}" + + r = 6 + slopes = [x for x in recs if x["type"] == "slope"] + for rec in slopes: + a = rec["assume"] + for cond, after in rec["after"].items(): + before = rec["before"].get(cond) or {} + put_row(r, row6) + _set(sheet, f"A{r}", name(rec)) + _set(sheet, f"B{r}", f"{SIDE_NAMES[a['side']]} · {ROLE_NAMES[a['role']]}") + _set(sheet, f"C{r}", _assume_text(rec)) + _set(sheet, f"D{r}", CONDITION_NAMES.get(cond, cond)) + _set(sheet, f"E{r}", before.get("fs")) + _set(sheet, f"F{r}", after.get("fs")) + _set(sheet, f"G{r}", after.get("required")) + _verdict(sheet, f"H{r}", before.get("ok")) + _verdict(sheet, f"I{r}", after.get("ok")) + r += 1 + if not slopes: + put_row(r, row6) + _set(sheet, f"A{r}", "가정 없음") + r += 1 + r += 1 + put_row(r, tpl[8]) + sheet["병합"].append(f"A{r}:I{r}") + sheet["행"][str(r)] = sheet["행"][str(r + 1)] = {"높이": 22} + put_row(r + 1, tpl[9]) + r += 2 + walls = [x for x in recs if x["type"] == "wall"] + for rec in walls: + p = rec["params"] + for wall in rec["walls"]: + for case in wall["cases"]: + put_row(r, tpl[10]) + _set(sheet, f"A{r}", name(rec)) + _set( + sheet, + f"B{r}", + f"{wall.get('name') or '벽'} · {CASE_NAMES[case['case']]} · " + f"{'Coulomb' if p['method'] == 'coulomb' else 'Rankine'} · 수위 {p['water_ratio']:g}H", + ) + for c, need, check in zip("CEG", "DFH", CHECKS): + fs = case["fs"][check] + _set(sheet, f"{c}{r}", fs if fs == fs and abs(fs) < 1e6 else None) + _set(sheet, f"{need}{r}", case["required"][check]) + _verdict(sheet, f"I{r}", all(case["ok"].values())) + r += 1 + if not walls: + put_row(r, tpl[10]) + _set(sheet, f"A{r}", "벽 검토 없음") diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py index ac96b8429..8579210ed 100644 --- a/E05_StabReport/E05_StabReport_Store.py +++ b/E05_StabReport/E05_StabReport_Store.py @@ -117,8 +117,15 @@ def refresh(project_root: Path) -> dict[str, Any]: stamp = datetime.now().strftime("%Y%m%d_%H%M%S") shutil.copy2(folder / REPORT, old / f"{stamp}.json") have = {s["id"] for s in book["시트"]} - sheets = [built.get(s["id"], s) for s in book["시트"]] - sheets += [built[sid] for sid in PROGRAM_SHEETS if sid not in have] # 지운 시트는 끝에 다시 + # 새 판에 없는 프로그램 몫(보강검토 — 저장본 없음)은 뺌 + sheets = [ + built.get(s["id"], s) + for s in book["시트"] + if s["id"] in built or s["id"] not in PROGRAM_SHEETS + ] + sheets += [ + built[sid] for sid in PROGRAM_SHEETS if sid in built and sid not in have + ] # 지운 시트는 끝에 다시 book = with_server_values({**book, "시트": sheets}) figures = _write_figures(folder, jobs) new_meta = _meta(book, inputs, figures) diff --git a/main.py b/main.py index f36ca04af..a1ce11a71 100644 --- a/main.py +++ b/main.py @@ -85,6 +85,7 @@ from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router +from E03_StabSection.E03_StabSection_Router_WhatIf import router as e03_stab_whatif_router from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -497,6 +498,7 @@ app.include_router(e04_stab_3d_router, dependencies=protected_with_company) app.include_router(e05_stab_report_router, dependencies=protected_with_company) app.include_router(e07_stab_area_router, dependencies=protected_with_company) app.include_router(e03_stab_section_router, dependencies=protected_with_company) +app.include_router(e03_stab_whatif_router, dependencies=protected_with_company) app.include_router(e06_stab_rock_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 469361b41..78c2f3835 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -3078,6 +3078,351 @@ 21 ] } + }, + { + "id": "s8", + "이름": "보강검토", + "칸": { + "A1": { + "값": "보강 검토 (가정)", + "서식": 1 + }, + "B1": { + "서식": 1 + }, + "C1": { + "서식": 1 + }, + "D1": { + "서식": 1 + }, + "E1": { + "서식": 1 + }, + "F1": { + "서식": 1 + }, + "G1": { + "서식": 1 + }, + "H1": { + "서식": 1 + }, + "I1": { + "서식": 1 + }, + "A2": { + "값": "E 안 가정 검토 — 비탈 완화 · 소단은 형상만 바꿔 다시 해석 · 설계(임도)에 안 돌려 씀 · 문헌 추정치", + "서식": 2 + }, + "B2": { + "서식": 2 + }, + "C2": { + "서식": 2 + }, + "D2": { + "서식": 2 + }, + "E2": { + "서식": 2 + }, + "F2": { + "서식": 2 + }, + "G2": { + "서식": 2 + }, + "H2": { + "서식": 2 + }, + "I2": { + "서식": 2 + }, + "A4": { + "값": "1. 비탈 완화 · 소단 가정 — 전후 안전율 (Bishop)", + "서식": 3 + }, + "B4": { + "서식": 3 + }, + "C4": { + "서식": 3 + }, + "D4": { + "서식": 3 + }, + "E4": { + "서식": 3 + }, + "F4": { + "서식": 3 + }, + "G4": { + "서식": 3 + }, + "H4": { + "서식": 3 + }, + "I4": { + "서식": 3 + }, + "A5": { + "값": "측점", + "서식": 6 + }, + "B5": { + "값": "비탈", + "서식": 6 + }, + "C5": { + "값": "가정", + "서식": 6 + }, + "D5": { + "값": "조건", + "서식": 6 + }, + "E5": { + "값": "전 Fs", + "서식": 6 + }, + "F5": { + "값": "후 Fs", + "서식": 6 + }, + "G5": { + "값": "기준", + "서식": 6 + }, + "H5": { + "값": "전 판정", + "서식": 6 + }, + "I5": { + "값": "후 판정", + "서식": 6 + }, + "A6": { + "서식": 7 + }, + "B6": { + "서식": 7 + }, + "C6": { + "서식": 5 + }, + "D6": { + "서식": 7 + }, + "E6": { + "서식": 8 + }, + "F6": { + "서식": 8 + }, + "G6": { + "서식": 8 + }, + "H6": { + "서식": 7 + }, + "I6": { + "서식": 7 + }, + "A8": { + "값": "2. 벽 외적 안정 — 활동 · 전도 · 지지력 (KDS 11 80 05)", + "서식": 3 + }, + "B8": { + "서식": 3 + }, + "C8": { + "서식": 3 + }, + "D8": { + "서식": 3 + }, + "E8": { + "서식": 3 + }, + "F8": { + "서식": 3 + }, + "G8": { + "서식": 3 + }, + "H8": { + "서식": 3 + }, + "I8": { + "서식": 3 + }, + "A9": { + "값": "측점", + "서식": 6 + }, + "B9": { + "값": "벽 · 경우", + "서식": 6 + }, + "C9": { + "값": "활동 Fs", + "서식": 6 + }, + "D9": { + "값": "기준", + "서식": 6 + }, + "E9": { + "값": "전도 Fs", + "서식": 6 + }, + "F9": { + "값": "기준", + "서식": 6 + }, + "G9": { + "값": "지지력 Fs", + "서식": 6 + }, + "H9": { + "값": "기준", + "서식": 6 + }, + "I9": { + "값": "판정", + "서식": 6 + }, + "A10": { + "서식": 7 + }, + "B10": { + "서식": 5 + }, + "C10": { + "서식": 8 + }, + "D10": { + "서식": 8 + }, + "E10": { + "서식": 8 + }, + "F10": { + "서식": 8 + }, + "G10": { + "서식": 8 + }, + "H10": { + "서식": 8 + }, + "I10": { + "서식": 7 + }, + "A12": { + "값": "가정 · 벽 검토 없음 — E03 [보강 검토] 뒤 [보고서 갱신]", + "서식": 5 + }, + "B12": { + "서식": 5 + }, + "C12": { + "서식": 5 + }, + "D12": { + "서식": 5 + }, + "E12": { + "서식": 5 + }, + "F12": { + "서식": 5 + }, + "G12": { + "서식": 5 + }, + "H12": { + "서식": 5 + }, + "I12": { + "서식": 5 + } + }, + "병합": [ + "A1:I1", + "A2:I2", + "A4:I4", + "A8:I8", + "A12:I12" + ], + "열": { + "A": { + "폭": 11 + }, + "B": { + "폭": 14 + }, + "C": { + "폭": 20 + }, + "D": { + "폭": 8 + }, + "E": { + "폭": 9 + }, + "F": { + "폭": 9 + }, + "G": { + "폭": 9 + }, + "H": { + "폭": 9 + }, + "I": { + "폭": 9 + } + }, + "행": { + "1": { + "높이": 36 + }, + "2": { + "높이": 30 + }, + "4": { + "높이": 22 + }, + "5": { + "높이": 22 + }, + "8": { + "높이": 22 + }, + "9": { + "높이": 22 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + } + } } ], "활성": "s1" diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index 52bb6e56a..b2a94f85f 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -90,3 +90,13 @@ | 22 행부터 보통 · 높음 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 측점 등급 + SI<1 비율 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | - 22 행 서식을 줄마다 복사. 측점 등급 = 영향 구역(측점 앞뒤 ½ 간격 × 띠 폭) 안 SI < 1(등급 4 이상) 셀 비율 — 낮음 < 5 % · 보통 5~20 % · 높음 ≥ 20 %(문턱 = E07 설정). + +## s8 보강검토 (프로그램 몫 · E03 보강 검토 저장본 · 없으면 시트를 뺌) + +| 칸 | 내용 | +| --- | --- | +| 6 행부터 가정 하나 × 조건 하나 = 한 줄 | A 측점 · B 비탈(좌 · 우 · 깎기 · 쌓기)(서식 7) · C 가정 글(1:n 전 → 후 · 소단 폭 / 높이 · 최소 완화)(5) · D 조건(7) · E 전 Fs · F 후 Fs · G 기준(8) · H 전 판정 · I 후 판정(7 → N.G 10 · O.K 11) · 없으면 「가정 없음」 | +| 가정 줄 다음 한 줄 띄고 `8` · `9` 행 꼴(제목 3 · 머리 6) | 2. 벽 외적 안정 | +| 그 아래 벽 하나 × 경우 하나 = 한 줄 | A 측점(7) · B 벽 이름 · 평상시 / 지진 시 · 토압법 · 배면 수위(5) · C 활동 · E 전도 · G 지지력 Fs(8) · D · F · H 기준(8 · KDS 11 80 05) · I 판정(7 → 10 · 11) · 없으면 「벽 검토 없음」 | + +- 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나. diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 8eee5c646..0a6681409 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -36,7 +36,7 @@ STYLE_KEYS = { } # fmt: skip PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"} A1 = re.compile(r"^([A-Z]{1,3})(\d+)$") -NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] +NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토"] needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -148,7 +148,7 @@ def test_양식_계약(): book = _form() _contract(book) assert [s["이름"] for s in book["시트"]] == NAMES - assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7"] + assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"] def test_양식_인쇄_쪽나눔_머리행_그림(): @@ -167,6 +167,7 @@ def test_양식_인쇄_쪽나눔_머리행_그림(): "대표단면": 1, "3D요약": 1, "광역요약": 0, + "보강검토": 0, } # N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀) assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9 @@ -230,8 +231,8 @@ def test_견본_pdf_쪽수_머리행(png): ) sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t] assert len(sections) == 2 # 단면마다 한 쪽 - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 - assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1(양식 빈 판) + assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab) html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0] assert html.count(" bool: + return abs(a - b) <= tol * abs(b) + + +# ── 벽 외적 안정 ───────────────────────────────────────────────────────────── +def test_토압_지지력_계수_교과서표(): + """Das(기초공학) 표 — Coulomb δ = 2/3φ · Rankine 경사 배면 · Meyerhof 지지력 계수.""" + assert close(wc.ka_coulomb(30, 20, 0, 0), 0.2973) + assert close(wc.ka_coulomb(30, 0, 0, 0), 1 / 3) + assert close(wc.ka_rankine(30, 0), 1 / 3) + assert close(wc.ka_rankine(30, 10), 0.3495) + nc, nq, ng = wc.bearing_factors(30) + assert close(nc, 30.14) and close(nq, 18.40) and close(ng, 15.67) + assert wc.bearing_factors(0) == (5.14, 1.0, 0.0) + + +def test_MO_닫힌꼴(): + """δ = θ = β = 0 · kv = 0 — KAE = cos²(φ−ψ) / (cos²ψ [1 + √(sinφ sin(φ−ψ) / cosψ)]²) · ψ = 0 이면 Coulomb.""" + for phi, kh in ((30, 0.2), (35, 0.15)): + psi = math.atan(kh) + f = math.radians(phi) + want = math.cos(f - psi) ** 2 / ( + math.cos(psi) ** 2 + * (1 + math.sqrt(math.sin(f) * math.sin(f - psi) / math.cos(psi))) ** 2 + ) + assert close(wc.ka_coulomb(phi, 0, 0, 0, math.degrees(psi)), want, 1e-9) + assert close(wc.ka_coulomb(30, 0, 0, 0, math.degrees(math.atan(0.2))), 0.4733) + assert wc.ka_coulomb(32, 21, 5, 10, 0.0) == pytest.approx(wc.ka_coulomb(32, 21, 5, 10)) + + +RECT = [[0, 0], [2, 0], [2, 4], [0, 4]] # B 2 · H 4 · 배면 = +x(뒷굽 x = 2) + + +def test_직사각_벽_손계산(): + """Rankine · q 0 · 물 없음 · μ 0.5 — W 184 · Pa 48(H/3) · 활동 1.917 · 전도 2.875 · + e = 1 − 0.6522 · q_max · Meyerhof q_ult(유효폭 · 경사 계수).""" + r = wc.check_wall( + RECT, +1, backfill=SOIL, foundation=SOIL, method="rankine", + surcharge_kpa=0.0, base_friction=0.5, + ) # fmt: skip + W, Pa = 2 * 4 * 23.0, 0.5 * 18 * 16 / 3 + assert close(r["loads"]["W"], W) and close(r["loads"]["Pa"], Pa) and r["loads"]["W_soil"] == 0 + assert close(r["fs"]["sliding"], W * 0.5 / Pa) + m_res, m_over = W * 1.0, Pa * 4 / 3 + assert close(r["fs"]["overturning"], m_res / m_over) + xbar = (m_res - m_over) / W + e = 1.0 - xbar + q_max = W / 2 * (1 + 6 * e / 2) if e <= 2 / 6 else 2 * W / (3 * xbar) + alpha = math.degrees(math.atan(Pa / W)) + q_ult = 0.5 * 18 * (2 - 2 * e) * 15.668 * (1 - alpha / 30) ** 2 + assert close(r["base"]["q_max"], q_max) and close(r["fs"]["bearing"], q_ult / q_max) + assert r["case"] == "normal" and r["required"] == wc.REQUIRED["normal"] + assert r["ok"] == {k: r["fs"][k] >= r["required"][k] for k in r["fs"]} + + +def test_상재하중_수압_양압력(): + """q 10 · 배면 수위 = 벽 높이 — Pa = Ka(½γ'H² + qH) · Pw = ½γwH² · U = ½γwHB · ΣH · V.""" + r = wc.check_wall( + RECT, +1, backfill=SOIL, foundation=SOIL, method="rankine", + surcharge_kpa=10.0, water_ratio=1.0, base_friction=0.5, + ) # fmt: skip + g = SOIL["gamma_sat"] - wc.GAMMA_W + Pa = (0.5 * g * 16 + 10 * 4) / 3 + Pw, U = 0.5 * wc.GAMMA_W * 16, 0.5 * wc.GAMMA_W * 4 * 2 + assert close(r["loads"]["Pa"], Pa) and close(r["loads"]["Pw"], Pw) and close(r["loads"]["U"], U) + assert close(r["loads"]["H"], Pa + Pw) and close(r["loads"]["V"], 184 - U) + arm = (0.5 * g * 16 * 4 / 3 + 40 * 2) / (0.5 * g * 16 + 40) # 삼각 + 사각 합력 높이 + assert close(r["loads"]["Pa_arm"], arm) + m_over = Pa * arm + Pw * 4 / 3 + U * 4 / 3 + assert close(r["loads"]["M_overturn"], m_over) + + +def test_L형_벽_뒷굽_위_흙_지진(): + """역 T 형(앞굽 0 · 뒷굽 3 · 벽체 0.6~1.0) — Rankine 가상면 위 흙 = (3 − 1.0) × 4.5 × γ · + Coulomb 가상면 θ = atan(2 / 5) · 지진 시 = M-O 증분 0.6H + 관성 · 기준 = 지진 시 표.""" + L = [[0, 0], [3, 0], [3, 0.5], [1.0, 0.5], [1.0, 5], [0.6, 5], [0.6, 0.5], [0, 0.5]] + r = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL, method="rankine") + assert close(r["loads"]["W_soil"], 2.0 * 4.5 * 18) + c = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL) + assert close(c["geometry"]["theta_deg"], math.degrees(math.atan(2 / 5))) + # 가상면(x = 3 − 0.4z)과 벽체(x = 1.0) 사이 · 0.5 ≤ z ≤ 5 → ∫(2 − 0.4z)dz = 4.05 m² + assert close(c["loads"]["W_soil"], 4.05 * 18) + s = wc.check_wall(L, +1, backfill=SOIL, foundation=SOIL, kh=0.15) + assert s["case"] == "seismic" and s["required"] == wc.REQUIRED["seismic"] + assert s["coefficients"]["kae"] > c["coefficients"]["ka"] + assert s["loads"]["H"] > c["loads"]["H"] and s["fs"]["sliding"] < c["fs"]["sliding"] + + +def test_배면이_반대쪽이면_거울(): + a = wc.check_wall(RECT, +1, backfill=SOIL, foundation=SOIL) + b = wc.check_wall([[-x, z] for x, z in RECT], -1, backfill=SOIL, foundation=SOIL) + for k in a["fs"]: + assert a["fs"][k] == pytest.approx(b["fs"][k]) + + +# ── 가정 형상 · 최소 완화 찾기 ──────────────────────────────────────────────── +GROUND = [(-40.0, 120.0), (0.0, 100.0), (40.0, 80.0)] # 1:2 비탈(좌 = +x 낮음) +LINE = [(-3.0, 101.5), (-2.0, 100.0), (2.0, 100.0), (3.0, 99.5)] + + +def test_비탈_다시_그림_해석해(): + """지반 z = 100 − x/2 · 우(−x) 깎기 시작 (−2, 100) · 1:n — u = −x 에서 (u − 2)/n = u/2 → + u = 2 / (1 − n/2) · z = 100 + u/2 · n ≥ 2 = 못 만남. 좌 쌓기 (2, 100) 1:1.5 → (8, 96).""" + for n in (0.5, 1.0, 1.5): + line = whatif.reshape_side(LINE, GROUND, side="right", role="cut", start_x=-2.0, ratio=n) + u = 2.0 / (1.0 - n / 2) + x, z = min(line) + assert x == pytest.approx(-u) and z == pytest.approx(100.0 + u / 2) + assert [p for p in line if p[0] > -2.0 + 1e-9] == [list(p) for p in LINE[2:]] + gentle = dict(side="right", role="cut", start_x=-2.0, ratio=2.5) + assert whatif.reshape_side(LINE, GROUND, **gentle) is None + line = whatif.reshape_side( + LINE, GROUND, side="right", role="cut", start_x=-2.0, ratio=0.5, bench_w=1.0, bench_h=1.0 + ) + corners = [p for p in line if -3.6 < p[0] < -2.4] + assert [[round(x, 6), round(z, 6)] for x, z in corners] == [[-3.5, 101.0], [-2.5, 101.0]] + fill = whatif.reshape_side(LINE, GROUND, side="left", role="fill", start_x=2.0, ratio=1.5) + assert max(fill) == pytest.approx([8.0, 96.0]) + assert [p for p in fill if p[0] < 2.0 - 1e-9] == [list(p) for p in LINE[:2]] + assert ( + whatif.reshape_side(LINE, GROUND, side="left", role="fill", start_x=2.0, ratio=3.0) is None + ) + + +def test_최소_완화_찾기(): + found = whatif.search_ratio(lambda n: n >= 1.23, 0.4) + assert found["found"] == pytest.approx(1.25) + window = whatif.search_ratio(lambda n: 1.0 <= n <= 1.3, 0.4) # 완만할수록 늘 안전하지 않음 + assert 1.0 <= window["found"] <= 1.3 + assert whatif.search_ratio(lambda n: False, 0.4, max_ratio=1.1)["found"] is None + assert whatif.search_ratio(lambda n: True, 0.4) == {"found": 0.4, "tried": [(0.4, True)]} + assert len(whatif.search_ratio(lambda n: n >= 3.9, 0.4)["tried"]) <= 10 + + +# ── 길(검증_sub2 동결 복사) ────────────────────────────────────────────────── +async def _copy(target: Path) -> None: + import aiomysql + + from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + connection = await aiomysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, db=DB_NAME, autocommit=True + ) + try: + await cp.copy_project( + connection, SOURCE_ID, scope="stability", new_id=PID, project_root=target + ) + finally: + connection.close() + + +@pytest.fixture(scope="module") +def frozen(): + folder = Path(tempfile.mkdtemp(prefix="aislo_e03_rf_")) + try: + asyncio.run(_copy(folder / "copy")) + except Exception as error: # noqa: BLE001 + shutil.rmtree(folder, ignore_errors=True) + pytest.skip(f"검증 원본 복사 실패: {error}") + yield folder / "copy" + shutil.rmtree(folder, ignore_errors=True) + + +def _fast(cross, design, **kw): + """진짜 엔진 — Bishop 원호만(빠르게).""" + return analyze_station(cross, design, **{**kw, "methods": ("bishop",), "noncircular": False}) + + +@pytest.fixture(scope="module") +def project(frozen): + """벽 측점 + 깎기 비탈 있는 벽 없는 측점 하나 · 진짜 엔진(빠르게) · [해석] 한 번.""" + mp = pytest.MonkeyPatch() + original = src.stations + + def pick(root): + rows = original(root) + wall = [r for r in rows if chainage_key(r["chainage_m"]) == WALL_KEY] + rest = [r for r in rows if chainage_key(r["chainage_m"]) != WALL_KEY] + return wall + rest[10:11] + + mp.setattr(router.src, "stations", pick) + + async def fake_project(_pid): + return frozen, {"company_id": 1, "project_kind": "STABILITY"} + + mp.setattr(e02, "_project", fake_project) + mp.setattr(worker, "analyze_station", _fast) + mp.setattr(wf, "analyze_station", _fast) + mp.setattr(router, "WORKERS", 1) + router._JOBS.clear() + + async def go(): + await router.analyze2d(UUID(PID)) + while router._TASKS: + await asyncio.gather(*list(router._TASKS)) + + asyncio.run(go()) + yield frozen + mp.undo() + + +def _stamps(root: Path) -> dict[str, bytes]: + out = {} + for kind in (router.KIND_STATION, router.KIND_SUMMARY): + for p in (root / "_derived" / kind).glob("*.json"): + out[str(p)] = p.read_bytes() # 읽기가 고친 때를 건드림(최근 표시) — 내용만 견줌 + return out + + +def _plain_station(root: Path) -> tuple[str, str, float, dict]: + summary, _ = router._latest_summary(root, "") + row = next(r for r in summary["stations"] if r["key"] != WALL_KEY) + side = next(s for s in row["sides"] if s["role"] == "cut")["side"] + return row["key"], side, row["cases"]["dry"][side]["fs"], row + + +def test_지금_기울기_그대로면_원본_안전율(project): + key, side, before, _ = _plain_station(project) + rec = wf.run_whatif(project, key, wf.WhatIfRequest(side=side)) + assert rec["assume"]["ratio"] == rec["assume"]["ratio_before"] + assert close(rec["after"]["dry"]["fs"], before, 0.03) + assert rec["view"]["analyses"] and rec["before"]["dry"]["fs"] == before + + +def test_최소_완화가_목표를_맞춤_원본_불변(project): + key, side, before, _ = _plain_station(project) + stamps = _stamps(project) + target = round(before + 0.02, 3) + rec = wf.run_whatif( + project, + key, + wf.WhatIfRequest(side=side, search=True, condition="dry", target_fs=target), + ) + search = rec["search"] + if search["found"] is None: # 완화가 깎기 높이를 키워 오히려 낮아지는 지형 — 끝값까지 봄 + assert all(not ok for _, ok in search["tried"]) + else: + assert rec["after"]["dry"]["fs"] >= target - 1e-9 + assert rec["assume"]["ratio"] == search["found"] > rec["assume"]["ratio_before"] + steeper = [n for n, ok in search["tried"] if n < search["found"]] + assert all(not ok for n, ok in search["tried"] if n in steeper) + assert _stamps(project) == stamps # 원본 측점 · 요약 저장본 그대로 + again = wf.run_whatif( + project, + key, + wf.WhatIfRequest(side=side, search=True, condition="dry", target_fs=target), + ) + assert again["computed_at"] == rec["computed_at"] # 같은 가정 = 저장본 + + +def test_벽_측점_외적_안정_벽쪽_가정_거절(project): + rec = wf.run_wallcheck(project, WALL_KEY, wf.WallRequest(kh=0.15)) + assert len(rec["walls"]) == 1 + wall = rec["walls"][0] + assert [c["case"] for c in wall["cases"]] == ["normal", "seismic"] + for c in wall["cases"]: + assert c["required"] == wc.REQUIRED[c["case"]] + assert all(v > 0 for v in c["fs"].values()) + assert c["geometry"]["H"] == pytest.approx(1.5, abs=0.05) + with pytest.raises(HTTPException) as error: + wf.run_whatif(project, WALL_KEY, wf.WhatIfRequest(side=wall["side"])) + assert error.value.status_code == 422 + + +def test_E05_보강검토_시트(project, monkeypatch): + from E05_StabReport import E05_StabReport_Build as build + + recs = wf.records(project) + assert {r["type"] for r in recs} == {"slope", "wall"} + book = build.form() + build.build_program(book, build.gather(project)) + s8 = next(s for s in book["시트"] if s["id"] == "s8") + cells = s8["칸"] + slopes = [r for r in recs if r["type"] == "slope"] + rows = sum(len(r["after"]) for r in slopes) + assert all(cells[f"D{6 + i}"]["값"] in ("건기", "우기", "지진") for i in range(rows)) + wall_rows = [a1 for a1, c in cells.items() if a1[0] == "B" and "평상시" in str(c.get("값"))] + assert len(wall_rows) == 1 and cells[f"I{wall_rows[0][1:]}"]["값"] in ("O.K", "N.G") + monkeypatch.setattr(build.reinforce, "read", lambda root: []) + empty = build.form() + build.build_program(empty, build.gather(project)) + assert "s8" not in {s["id"] for s in empty["시트"]} # 없으면 시트 뺌 diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index f51cdf387..3fa49b93b 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -123,4 +123,48 @@ export const ui_locales_e03 = { E03_Graph_Sigma: ["유효 수직응력 σ'", "Effective normal σ'"], E03_Graph_U: ["간극수압 u", "Pore pressure u"], E03_Graph_Tau: ["동원 전단응력 τ", "Mobilized shear τ"], + + /* --- 보강 검토 탭 --- */ + E03_Tab: ["보기", "View"], + E03_Tab_Result: ["해석 결과", "Analysis Result"], + E03_Tab_Reinforce: ["보강 검토", "Remediation Check"], + E03_Rf_Title: ["비탈 완화 · 소단 가정", "Slope Flattening · Bench Assumption"], + E03_Rf_Hint: [ + "형상만 바꿔 다시 해석합니다(가정 검토 · 설계 · 원본 결과는 그대로). 비탈 전체를 한 기울기로 갈음합니다.", + "Re-analyzes with the changed shape only (assumption · design and original results unchanged). The whole slope uses one gradient.", + ], + E03_Rf_Ratio: ["기울기 1:n", "Slope 1:n"], + E03_Rf_RatioNow: ["지금 값", "Current"], + E03_Rf_BenchW: ["소단 폭 m", "Bench width m"], + E03_Rf_BenchH: ["소단 높이 m 마다", "Bench every m"], + E03_Rf_Bench: ["소단", "Bench"], + E03_Rf_Run: ["가정 해석", "Analyze Assumption"], + E03_Rf_Search: ["최소 완화 찾기", "Find Minimum Flattening"], + E03_Rf_Busy: ["계산 중…", "Computing…"], + E03_Rf_Goal: [ + "[최소 완화 찾기] 목표 = 지금 조건({c}) 기준안전율 · 한 번에 수십 초~1분", + "[Find] target = required FS of the current condition ({c}) · takes up to a minute", + ], + E03_Rf_Found: ["기준안전율을 맞추는 가장 급한 기울기", "Steepest gradient meeting required FS"], + E03_Rf_NotFound: [ + "못 찾음 — 원지반을 만나는 가장 완만한 1:{n} 까지 봄", + "Not found — checked up to 1:{n} (gentlest that meets the ground)", + ], + E03_Rf_Before: ["전 Fs", "FS before"], + E03_Rf_After: ["후 Fs", "FS after"], + E03_Rf_VerdictBefore: ["전 판정", "Before"], + E03_Rf_VerdictAfter: ["후 판정", "After"], + E03_Rf_AfterSection: ["가정 단면", "Assumed Section"], + E03_Rf_WallTitle: ["벽 외적 안정 (KDS 11 80 05)", "Wall External Stability (KDS 11 80 05)"], + E03_Rf_Method: ["토압", "Earth pressure"], + E03_Rf_Water: ["배면 수위 ÷ 벽 높이", "Water level ÷ wall height"], + E03_Rf_WallRun: ["외적 안정 검토", "Check Wall"], + E03_Rf_Wall: ["벽", "Wall"], + E03_Rf_Case_normal: ["평상시", "Normal"], + E03_Rf_Case_seismic: ["지진 시", "Seismic"], + E03_Rf_Check: ["검토", "Check"], + E03_Rf_Fs: ["안전율", "FS"], + E03_Rf_sliding: ["활동", "Sliding"], + E03_Rf_overturning: ["전도", "Overturning"], + E03_Rf_bearing: ["지지력", "Bearing"], } as const satisfies Record; From 29ab719ff6a0f33fc6b23ecff4005c060c63aac8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 00:15:08 +0900 Subject: [PATCH 32/34] =?UTF-8?q?fix(536-3):=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8B=A4=EB=93=AC=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=80=EC=A0=95=20=EB=92=A4=20=EC=A0=84=20=EA=B7=B8=EB=A6=BC?= =?UTF-8?q?=20=3D=20=EA=B0=80=EC=A0=95=ED=95=9C=20=EB=B9=84=ED=83=88=20?= =?UTF-8?q?=C2=B7=20E05=20=EB=B3=B4=EA=B0=95=EA=B2=80=ED=86=A0=20=EC=97=B4?= =?UTF-8?q?=20=ED=8F=AD=20=C2=B7=20=EA=B0=80=EC=A0=95=20=EA=B8=80=EC=97=90?= =?UTF-8?q?=20=EC=B5=9C=EC=86=8C=20=EC=99=84=ED=99=94=20=EB=AA=A9=ED=91=9C?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- E03_StabSection/E03_StabSection_UI_Page.ts | 7 ++++--- E05_StabReport/E05_StabReport_Reinforce.py | 7 ++++++- .../master_template/report/stab_report.json | 18 +++++++++--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index f60d17cb3..0d0846373 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -378,9 +378,10 @@ export function renderE03StabSection(root: HTMLElement): void { } } const runSlope = (key: string, ask: SlopeAsk): Promise => - reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => - slopeRecs.set(key, rec), - ); + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { + slopeRecs.set(key, rec); + side = rec.assume.side; // 전 그림 = 가정한 비탈 + }); const runWall = (key: string, ask: WallAsk): Promise => reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => wallRecs.set(key, rec), diff --git a/E05_StabReport/E05_StabReport_Reinforce.py b/E05_StabReport/E05_StabReport_Reinforce.py index 93f58008d..bd41246a7 100644 --- a/E05_StabReport/E05_StabReport_Reinforce.py +++ b/E05_StabReport/E05_StabReport_Reinforce.py @@ -58,7 +58,12 @@ def _assume_text(rec: dict[str, Any]) -> str: text += f" · 소단 폭 {a['bench_w']:g} m / 높이 {a['bench_h']:g} m 마다" search = rec.get("search") if search: - text += " · 최소 완화" if search.get("found") else " · 최소 완화 못 찾음(끝값)" + goal = CONDITION_NAMES.get(search.get("condition"), "전 조건") + text += ( + f" · 최소 완화({goal} 목표)" + if search.get("found") + else f" · 최소 완화 못 찾음({goal} · 끝값)" + ) return text diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 78c2f3835..585ab1a29 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -3359,31 +3359,31 @@ ], "열": { "A": { - "폭": 11 + "폭": 15 }, "B": { - "폭": 14 + "폭": 28 }, "C": { - "폭": 20 + "폭": 30 }, "D": { - "폭": 8 + "폭": 7 }, "E": { - "폭": 9 + "폭": 8 }, "F": { - "폭": 9 + "폭": 8 }, "G": { - "폭": 9 + "폭": 8 }, "H": { - "폭": 9 + "폭": 8 }, "I": { - "폭": 9 + "폭": 8 } }, "행": { From fd963fd7a7a3d6f6ed499f9fca3ba7c59bd1b204 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 00:32:22 +0900 Subject: [PATCH 33/34] =?UTF-8?q?feat(345-=EB=8F=84=EC=9B=80=EB=A7=90):=20?= =?UTF-8?q?D=20=C2=B7=20E=20=ED=8E=98=EC=9D=B4=EC=A7=80=2011=20=EA=B0=9C?= =?UTF-8?q?=20[=3F]=20=EB=8F=84=EC=9B=80=EB=A7=90=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=84=A4=EB=AA=85=EC=84=9C=20=C2=B7=20=EC=B9=B8=20?= =?UTF-8?q?=EB=9C=BB=20=C2=B7=20=EC=93=B0=EC=9D=B8=20=EB=B0=A9=EB=B2=95?= =?UTF-8?q?=EA=B3=BC=20=EA=B7=BC=EA=B1=B0=20=C2=B7=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=99=95=EC=A0=95=20=EC=A0=84=20=EA=B0=92=20=C2=B7?= =?UTF-8?q?=20=EB=8F=84=EC=9B=80=EB=A7=90=20=EC=84=9C=EB=B2=84=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20E06=20=C2=B7=20E07=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=ED=82=B4=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- common_util/common_util_help_router.py | 2 +- resources/help/D01/00_설명서.md | 35 +++++++++ resources/help/D01/10_칸_뜻.md | 36 +++++++++ resources/help/D02/00_설명서.md | 59 +++++++++++++++ resources/help/D02/10_칸_뜻.md | 25 +++++++ resources/help/D02/20_쓰인_방법과_근거.md | 34 +++++++++ resources/help/D03/00_설명서.md | 80 ++++++++++++++++++++ resources/help/D03/20_쓰인_방법과_근거.md | 52 +++++++++++++ resources/help/D03/30_사용자_확정_전_값.md | 21 ++++++ resources/help/D04/00_설명서.md | 81 +++++++++++++++++++++ resources/help/D04/10_내보내기_파일_구성.md | 64 ++++++++++++++++ resources/help/D04/30_사용자_확정_전_값.md | 18 +++++ resources/help/E01/00_설명서.md | 55 ++++++++++++++ resources/help/E02/00_설명서.md | 50 +++++++++++++ resources/help/E02/10_칸_뜻.md | 75 +++++++++++++++++++ resources/help/E02/20_쓰인_방법과_근거.md | 60 +++++++++++++++ resources/help/E02/30_사용자_확정_전_값.md | 58 +++++++++++++++ resources/help/E03/00_설명서.md | 69 ++++++++++++++++++ resources/help/E03/10_칸_뜻.md | 73 +++++++++++++++++++ resources/help/E03/20_쓰인_방법과_근거.md | 81 +++++++++++++++++++++ resources/help/E04/00_설명서.md | 68 +++++++++++++++++ resources/help/E04/10_칸_뜻.md | 51 +++++++++++++ resources/help/E04/20_쓰인_방법과_근거.md | 74 +++++++++++++++++++ resources/help/E04/30_사용자_확정_전_값.md | 30 ++++++++ resources/help/E05/00_설명서.md | 55 ++++++++++++++ resources/help/E05/10_시트_구성.md | 66 +++++++++++++++++ resources/help/E06/00_설명서.md | 72 ++++++++++++++++++ resources/help/E06/10_칸_뜻.md | 51 +++++++++++++ resources/help/E06/20_쓰인_방법과_근거.md | 72 ++++++++++++++++++ resources/help/E07/00_설명서.md | 76 +++++++++++++++++++ resources/help/E07/10_칸_뜻.md | 56 ++++++++++++++ resources/help/E07/20_쓰인_방법과_근거.md | 69 ++++++++++++++++++ resources/help/E07/30_사용자_확정_전_값.md | 20 +++++ resources/tester/test_54_2_help_files.py | 20 ++++- 34 files changed, 1805 insertions(+), 3 deletions(-) create mode 100644 resources/help/D01/00_설명서.md create mode 100644 resources/help/D01/10_칸_뜻.md create mode 100644 resources/help/D02/00_설명서.md create mode 100644 resources/help/D02/10_칸_뜻.md create mode 100644 resources/help/D02/20_쓰인_방법과_근거.md create mode 100644 resources/help/D03/00_설명서.md create mode 100644 resources/help/D03/20_쓰인_방법과_근거.md create mode 100644 resources/help/D03/30_사용자_확정_전_값.md create mode 100644 resources/help/D04/00_설명서.md create mode 100644 resources/help/D04/10_내보내기_파일_구성.md create mode 100644 resources/help/D04/30_사용자_확정_전_값.md create mode 100644 resources/help/E01/00_설명서.md create mode 100644 resources/help/E02/00_설명서.md create mode 100644 resources/help/E02/10_칸_뜻.md create mode 100644 resources/help/E02/20_쓰인_방법과_근거.md create mode 100644 resources/help/E02/30_사용자_확정_전_값.md create mode 100644 resources/help/E03/00_설명서.md create mode 100644 resources/help/E03/10_칸_뜻.md create mode 100644 resources/help/E03/20_쓰인_방법과_근거.md create mode 100644 resources/help/E04/00_설명서.md create mode 100644 resources/help/E04/10_칸_뜻.md create mode 100644 resources/help/E04/20_쓰인_방법과_근거.md create mode 100644 resources/help/E04/30_사용자_확정_전_값.md create mode 100644 resources/help/E05/00_설명서.md create mode 100644 resources/help/E05/10_시트_구성.md create mode 100644 resources/help/E06/00_설명서.md create mode 100644 resources/help/E06/10_칸_뜻.md create mode 100644 resources/help/E06/20_쓰인_방법과_근거.md create mode 100644 resources/help/E07/00_설명서.md create mode 100644 resources/help/E07/10_칸_뜻.md create mode 100644 resources/help/E07/20_쓰인_방법과_근거.md create mode 100644 resources/help/E07/30_사용자_확정_전_값.md diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py index f08a44ba8..6960521b7 100644 --- a/common_util/common_util_help_router.py +++ b/common_util/common_util_help_router.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException router = APIRouter(prefix="/api/help", tags=["help"]) HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help" -PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-5])$") +PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-7])$") def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: diff --git a/resources/help/D01/00_설명서.md b/resources/help/D01/00_설명서.md new file mode 100644 index 000000000..0647091dd --- /dev/null +++ b/resources/help/D01/00_설명서.md @@ -0,0 +1,35 @@ +# 예정노선 등록 설명서 + +## 이 화면에서 하는 일 +- 여러 해에 걸친 예정노선의 기본 정보(사업 연도 · 지역 · 임도 종류 · 설계속도 · 이름)를 넣고 새 예정노선 프로젝트를 만드는 첫 화면임. +- 대시보드의 [+] 에서 「예정노선」 카드를 고르면 이 화면이 열림. +- 도면 관련 칸은 없음. 노선의 실제 위치는 만든 뒤 다음 화면(1단계 기준 지정)에서 정함. +- 예정노선은 **산림청만** 만들 수 있음. 산림청 = 시스템 관리자 또는 메일 주소가 `.go.kr` 로 끝나는 사용자임. + +## 작업 순서 +1. 대시보드에서 [+] 를 누르고 「예정노선」 카드를 고름. 산림청이 아니면 카드가 흐리게 보이고 누르면 「예정노선은 산림청(시스템 관리자 · go.kr 메일)만 만들 수 있습니다.」 안내가 나옴. +2. 「사업 연도 시작」 · 「사업 연도 끝」 에 사업 기간을 넣음. 한 해짜리면 두 칸을 같은 해로 둠. +3. 「사업 지역」 을 적음. 필수 칸임. +4. 「임도 종류」 를 고르고, 그에 맞춰 「설계속도」 를 고름. +5. 「프로젝트명」 을 확인함. 위 칸을 채우면 자동으로 이름이 만들어지며, 직접 고쳐도 됨. +6. 필요하면 「노선 시작 누가거리 (m)」 · 「노선 종료 누가거리 (m)」 를 넣음. 비워 두어도 됨. +7. [등록] 을 누름. 칸에 문제가 있으면 해당 칸 아래에 빨간 안내가 나오고 등록되지 않음. + +## 단추 +| 단추 | 하는 일 | +| --- | --- | +| [?] | 이 도움말을 엶 | +| [취소] | 등록하지 않고 대시보드로 돌아감 | +| [등록] | 입력한 값으로 예정노선 프로젝트를 만들고 곧바로 D02 「기준 지정」 화면으로 감 | + +## 결과 읽는 법 +- 등록에 성공하면 「프로젝트를 만들었습니다.」 알림이 뜨고, 만든 프로젝트가 현재 프로젝트가 되어 「기준 지정」 화면(지도 화면)이 열림. +- 대시보드 프로젝트 표에도 종류 「예정노선」 으로 올라감. 표의 단계 막대(기준 지정 · 노선 분석 · 확정 · 내보내기)를 누르면 그 단계 화면으로 감. +- 실패하면 서버가 알려 준 까닭이 알림으로 뜨고 이 화면에 머묾. + +## 알아 둘 것 +- 칸마다 뜻과 기본값은 「칸 뜻」 문서에 있음. +- 임도 종류 · 설계속도는 임도설계(B 그룹)와 같은 기준을 씀. 등록 뒤 이 화면에서 고치는 방법은 코드에서 확인하지 못함. +- 누가거리(노선을 따라 잰 거리) 두 칸은 저장만 되며, 다음 화면들에서 쓰이는 곳은 확인하지 못함. 「분석 뒤 실제 누가와 다르면 차이 표시」 는 준비 중으로 봄. +- 등록 직후에는 노선 위치가 비어 있음. 지점을 정하고 저장해야 분석 단계로 갈 수 있음. +- 지정받은 다른 회사 구성원은 예정노선을 보기만 할 수 있고 만들거나 고칠 수 없음. diff --git a/resources/help/D01/10_칸_뜻.md b/resources/help/D01/10_칸_뜻.md new file mode 100644 index 000000000..692332260 --- /dev/null +++ b/resources/help/D01/10_칸_뜻.md @@ -0,0 +1,36 @@ +# 칸 뜻 + +| 칸 | 뜻 | 기본값 | 범위 · 규칙 | +| --- | --- | --- | --- | +| 사업 연도 시작 | 사업 첫 해 | 올해 | 2000 ~ 2100 의 정수 | +| 사업 연도 끝 | 사업 마지막 해 | 올해 | 시작 연도와 같거나 뒤여야 함 · 2100 까지 | +| 사업 지역 | 사업이 있는 지역 이름 | 비어 있음 | 필수 · 글자 100 자까지 · 예: 울진군 금강송면 | +| 종류 | 프로젝트 종류 | 「노선 계획」 | 고정 · 고칠 수 없음 | +| 임도 종류 | 간선임도 · 산불진화임도 · 작업임도 중 하나 | 간선임도(목록 맨 위) | 설계속도 선택지와 이름 조합에 쓰임 | +| 설계속도 | 노선을 설계하는 기준 속도 | 20 km/h | 아래 표 참조 | +| 프로젝트명 | 대시보드에 보이는 이름 | 자동 조합 | 필수 · 글자 255 자까지 | +| 노선 시작 누가거리 (m) | 노선 첫 지점의 측점 값 | 비어 있음 | 0 이상 · 선택 | +| 노선 종료 누가거리 (m) | 노선 끝 지점의 계획 목표 거리 | 비어 있음 | 시작 값보다 커야 함 · 선택 | + +## 설계속도 선택지 +| 임도 종류 | 고를 수 있는 값 | +| --- | --- | +| 간선임도 | 20 · 30 · 40 km/h | +| 산불진화임도 | 20 · 30 · 40 km/h | +| 작업임도 | 20 km/h 만 | + +- 임도 종류를 바꾸면 설계속도 목록이 바뀌고 값이 가장 낮은 값(20 km/h)으로 돌아감. +- 임도설계(B 그룹)와 같은 기준이며, 허용되지 않는 값이 서버에 오면 20 km/h 로 처리함. + +## 프로젝트명 자동 조합 +- 「사업 연도」 · 「사업 지역」 · 「임도 종류」 를 띄어 이어 붙임. 예: 2026~2028 울진군 금강송면 간선임도 +- 연도가 여러 해이면 「시작~끝」 꼴이고, 한 해이면 해 하나만 들어감. +- 이름을 직접 고치기 전에는 위 칸을 바꿀 때마다 통째로 다시 만들어짐. +- 이름을 고친 뒤에는 위 칸이 바뀌어도 자동 조각이 차지한 부분만 새 값으로 바뀌고, 직접 넣은 글은 그대로 남음. 직접 지운 조각은 되살아나지 않음. + +## 입력 오류 안내 +- 시작 연도가 2000 ~ 2100 밖이면 숫자 범위 안내가 나옴. +- 끝 연도가 시작보다 앞이면 「끝 연도는 시작 연도와 같거나 뒤여야 합니다.」 가 나옴. +- 지역이나 이름이 비면 필수 입력 안내가 나옴. +- 종료 누가거리가 시작 이하이면 「종료 누가거리는 시작보다 커야 합니다.」 가 나옴. +- 산림청이 아닌 계정으로 등록하면 서버가 「예정노선은 산림청만 만들 수 있습니다.」 로 막음. 회사에 연결되지 않은 계정도 막힘. diff --git a/resources/help/D02/00_설명서.md b/resources/help/D02/00_설명서.md new file mode 100644 index 000000000..a8eda8d5a --- /dev/null +++ b/resources/help/D02/00_설명서.md @@ -0,0 +1,59 @@ +# 기준 지정 설명서 + +## 이 화면에서 하는 일 +- 예정노선 1단계. 사업 연도마다 노선의 **시점**과 **종점**을 지도 위에 정하는 화면임. +- 가운데 = 카카오맵(지점과 연도별 선이 그려짐). 왼쪽 = 「연도 지점」 표(지점 이름 · 경도 · 위도 입력칸). +- 정한 지점은 다음 단계(노선 분석)가 길을 찾는 기준이 됨. +- 지점 저장은 산림청만 할 수 있음. 지정받은 다른 회사는 보기만 가능하며 저장하면 「예정노선 지점은 산림청만 고칠 수 있습니다.」 로 막힘. + +## 작업 순서 +1. 화면이 열리면 사업 연도 수 + 1 개의 지점 줄이 표에 나타남. 예: 2026 ~ 2027 두 해이면 「2026 시점」 · 「2026 종점 · 2027 시점」 · 「2027 종점」 세 줄. +2. 처음에는 비어 있는 첫 줄이 놓을 지점으로 잡혀 있음(줄이 강조되어 보임). +3. 지도를 눌러 그 지점을 놓음. 놓으면 다음 빈 지점이 자동으로 놓을 지점이 됨. +4. 놓은 뒤 고치려면 지도의 표지를 끌어 옮기거나, 표의 경도 · 위도 칸에 값을 직접 넣음. +5. 모든 줄이 채워지면 연도마다 다른 색의 선이 지점 사이를 이음. +6. [저장] 또는 [>] 를 누름. + +## 지점 정하는 방법 +| 방법 | 하는 법 | 결과 | +| --- | --- | --- | +| 지도 누름 | 줄 옆 📍 로 놓을 지점을 고른 뒤 지도를 누름 | 그 자리에 표지가 놓임 | +| 표지 끌기 | 지도의 표지를 끌어 놓음 | 표의 값이 따라 바뀜 | +| 좌표 입력 | 경도 · 위도 두 칸에 값을 넣고 칸을 벗어남 | 값이 맞으면 표지가 옮겨짐 · 틀리면 칸이 빨갛게 표시됨 | + +- 놓을 지점을 고르지 않고 지도를 누르면 「지도에 놓을 지점 고르기」 안내가 나옴. +- 다른 프로그램에서 구한 좌표는 경도 · 위도(WGS84, 소수 7 자리까지 반영)로 넣을 것. +- 경도는 120 ~ 135, 위도는 30 ~ 45 범위만 받음. + +## 연도 표 읽는 법 +| 줄 이름 | 뜻 | +| --- | --- | +| 첫 연도 시점 | 노선 전체의 출발점 | +| 앞 연도 종점 · 다음 연도 시점 | 한 점이 두 역할. 앞 연도의 끝이자 다음 연도의 시작 | +| 마지막 연도 종점 | 노선 전체의 도착점 | + +- 직전 연도 종점과 다음 연도 시점은 항상 같은 점이며, 하나만 옮기면 두 연도의 선이 함께 움직임. +- 줄 색과 지도 선 색은 연도별로 대응함(6 색을 돌려 씀). + +## 단추 +| 단추 | 하는 일 | +| --- | --- | +| [?] | 이 도움말 | +| [<] | 이전 단계. 이 화면이 첫 단계라 흐림 | +| [>] | 지점 저장 뒤 다음 단계 「노선 분석」 으로 감 | +| [저장] | 지점을 저장하고 좌표계를 정함 | +| 📍 | 지도를 눌렀을 때 놓을 지점으로 이 줄을 고름 | +| 위 단계 막대 | 「기준 지정」 · 「노선 분석」 · 「확정 · 내보내기」 로 바로 이동 | + +## 결과 읽는 법 +- 저장하면 「지점을 저장했습니다.」 알림이 뜸. +- 표 아래 한 줄에 「저장 좌표계 EPSG:번호」 와 첫 연도 시점의 X · Y(m)가 나옴. 저장 전에는 「아직 저장 안 함」 이 나옴. +- 저장 전 고친 값은 이 화면을 다시 열어도 같은 브라우저 안에서는 남아 있음. 서버에는 [저장] 이나 [>] 를 눌러야 반영됨(자동 저장 없음). + +## 알아 둘 것 +- **[>] 와 수치지형도**: [>] 는 지점을 저장한 뒤 「노선 분석」 화면을 엶. 수치지형도(도엽) · 위성 사진 · 연도별 지형면 받기는 그 화면이 열릴 때 뒤에서 시작되며, 이미 받은 범위 안이면 건너뜀. 지점을 저장하지 못하면(예: 빈 지점) 이 화면에 머묾. +- 빈 지점이 남은 채 저장하면 「○○○○년 시점이 비었습니다.」 또는 「○○○○년 종점이 비었습니다.」 로 막힘. +- 연도 범위(시작 · 끝)는 등록 때 정해지며 이 화면에서 바꿀 수 없음. +- **이미 분석 · 확정한 연도의 지점을 옮기면** 그 연도의 분석 노선과 확정 상태가 비워지고 다시 분석해야 함. 1 mm 이내 변화는 같은 지점으로 보아 지우지 않음. +- 좌표계가 바뀌는 경우(「쓰인 방법과 근거」 문서의 좌표계 절)에는 모든 연도의 분석 노선이 비워짐. +- 지도는 카카오맵을 씀. 점이 하나도 없어도 우리나라 가운데쯤을 먼저 보여 줌. diff --git a/resources/help/D02/10_칸_뜻.md b/resources/help/D02/10_칸_뜻.md new file mode 100644 index 000000000..24a2720a4 --- /dev/null +++ b/resources/help/D02/10_칸_뜻.md @@ -0,0 +1,25 @@ +# 칸 뜻 + +## 왼쪽 「연도 지점」 표 +| 칸 | 뜻 | 규칙 | +| --- | --- | --- | +| 지점 이름 | 어느 연도의 시점 · 종점인지 | 예: 「2026 시점」 · 「2026 종점 · 2027 시점」 | +| 📍 | 지도 누름이 놓을 지점으로 고름 | 고른 줄이 강조됨 | +| 경도 | 동서 위치(도) | 120 ~ 135 · 소수 가능 | +| 위도 | 남북 위치(도) | 30 ~ 45 · 소수 가능 | + +- 경도 · 위도 중 하나만 넣으면 반영되지 않음. 두 칸이 모두 범위 안일 때 반영됨. +- 반영될 때 소수 7 자리(약 1 cm)로 반올림됨. +- 범위 밖 값은 두 칸이 모두 빨갛게 표시되고 반영되지 않음. + +## 지도 위 +- 표지 하나 = 지점 하나. 마우스로 끌어 옮길 수 있음. +- 선 하나 = 연도 하나(그 해의 시점에서 종점까지). 색은 연도별로 다름. +- 지점이 하나도 없으면 우리나라 가운데쯤이 먼저 보임. 지점을 놓으면 처음 한 번만 그 지점에 맞춰 보이고, 그 뒤로는 사용자가 맞춘 보기(중심 · 확대)를 그대로 둠. +- 지도 아래 안내문 = 「지도를 눌러 지점을 놓습니다.」 + +## 표 아래 한 줄 +| 표시 | 뜻 | +| --- | --- | +| 아직 저장 안 함 | 서버에 지점이 한 번도 저장되지 않음 | +| 저장 좌표계 EPSG:번호 · 연도 X · Y | 저장된 좌표계와 첫 연도 시점의 평면좌표(m, 소수 3 자리) | diff --git a/resources/help/D02/20_쓰인_방법과_근거.md b/resources/help/D02/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..712ba6ee5 --- /dev/null +++ b/resources/help/D02/20_쓰인_방법과_근거.md @@ -0,0 +1,34 @@ +# 쓰인 방법과 근거 + +## 좌표계 처리 +- 사용자가 넣는 값은 모두 경도 · 위도(WGS84)임. 지도(카카오맵)도 이 값으로 그려짐. +- 저장할 때 서버가 모든 지점의 **평균 경도**를 보고 한국 평면좌표(TM) 띠 하나를 고른 뒤, 모든 지점을 그 한 가지 좌표계의 X(동쪽) · Y(북쪽) 미터 값으로 바꿔 저장함. +- 분석과 지형 받기는 이 평면좌표를 씀. 사용자가 변환을 따로 할 필요는 없음. + +| 평균 경도 | 좌표계 | 이름 | +| --- | --- | --- | +| 126° 미만 | EPSG:5185 | 서부 | +| 126° 이상 128° 미만 | EPSG:5186 | 중부 | +| 128° 이상 | EPSG:5187 | 동부 | + +- 한 노선은 연도가 달라도 항상 같은 좌표계 한 벌을 씀(연도마다 띠를 따로 쓰지 않음). +- 동해(5188)는 쓰지 않음. +- 지점을 옮겨 평균 경도가 126° 나 128° 선을 넘으면 좌표계가 바뀌고, 그러면 모든 연도가 다시 저장되며 분석 노선이 비워짐. +- 내보내기 때의 좌표계(5179)는 확정 · 내보내기 단계에서 다룸. 이 화면에서는 쓰지 않음. + +## 직전 연도 종점 = 다음 연도 시점 +- 화면: 점 하나를 두 연도가 함께 쓰는 지점 목록(연도 수 + 1 개)으로 다룸. +- 서버: 저장할 때 첫 연도는 보낸 시점을 쓰고, 이후 연도의 시점은 항상 직전 연도의 종점으로 맞춤. 따로 다른 값을 보내도 무시됨. +- 사업 연도 밖의 연도가 오거나 같은 연도가 두 번 오면 거절됨. +- 비어 있는 시점 · 종점이 있으면 「○○○○년 시점(종점)이 비었습니다.」 로 거절됨. + +## 저장 규칙 +- 바뀐 연도만 새로 쓰고, 안 바뀐 연도(1 mm 이내)는 그대로 둠. +- 바뀐 연도는 이미 있던 분석 노선과 확정 시각을 지움. 한 묶음으로 처리되어 일부만 저장되는 일은 없음. +- 사업 연도 밖으로 남아 있던 연도 행은 함께 지움. +- 저장은 산림청 계정만 가능함(시스템 관리자 또는 `.go.kr` 메일). + +## 한계 +- 좌표 입력은 경위도만 받음. 평면좌표(X · Y)를 직접 넣는 칸은 없음. +- 지도 위 위치는 카카오맵 화면 기준이며, 정밀한 측량 값이 필요하면 좌표 입력으로 넣을 것. +- 지형 받기와 분석에 관한 설명은 「노선 분석」 화면의 도움말을 볼 것. diff --git a/resources/help/D03/00_설명서.md b/resources/help/D03/00_설명서.md new file mode 100644 index 000000000..f5c9fc505 --- /dev/null +++ b/resources/help/D03/00_설명서.md @@ -0,0 +1,80 @@ +# 예정노선 2단계 분석 설명서 + +## 이 화면에서 하는 일 + +- 1단계(기준 지정)에서 정한 지점들을 연도 구간마다 이어 주는 노선을 자동으로 찾아 줌. +- 연도마다 노선 후보 넷(영선 · 토공 최소 · 기울기 최소 · 비중 계산)을 만들어 보여 주고, 연도별로 하나씩 고름. +- 고른 후보를 이어 붙여 전체 예정노선 한 줄과 측점(20 m 간격)을 평면에 표시함. +- 측점을 누르면 그 자리 지반선 횡단을 아래에 보여 주고, [3D] 로 지형과 노선을 입체로 봄. +- 지점이 마음에 안 들면 평면에서 직접 끌어 옮긴 뒤 다시 분석 가능. +- 노선 후보를 찾는 엔진은 임도설계 노선 추천(B05)과 같은 엔진임. 이 화면에서는 시작점과 끝점만 정해진 두 지점 사이를 구간별로 한 번씩 찾음. + +## 화면 구성 + +- 가운데 위 = 평면 지도. 위성 사진 위에 등고선, 지점, 노선, 측점이 겹쳐 보임. +- 가운데 아래 = 횡단 판. 측점을 누르면 채워지며, 제목 줄(「횡단」)을 누르면 접고 펼 수 있음. +- 왼쪽 = 「지형」 카드, 「연도별 노선」 카드, 아래쪽 단추 [분석] [저장] [3D]. + +## 작업 순서 + +1. 화면을 열면 자동으로 지형을 확인함. 지점이 이미 받은 지형 범위 안이면 그대로 두고, 아니면 뒤에서 지형을 받기 시작함(왼쪽 「지형」 카드에 진행률 표시). +2. 지점 위치가 마음에 안 들면 평면에서 지점(원)을 끌어 옮김. 지점이 받은 지형 밖이면 [범위 밖 — 더 받기] 를 눌러 지형을 더 받음. +3. [분석] 을 누름. 연도 구간마다 후보 넷을 찾으며, 진행률이 「지형」 카드 자리에 표시됨. +4. 「연도별 노선」 카드에서 연도마다 후보 하나를 누름(눌린 카드가 강조됨). 평면의 노선과 누가(거리) 숫자가 곧바로 바뀜. +5. 평면의 측점 눈금을 눌러 횡단을 확인하고, 필요하면 [3D] 로 확인함. +6. 모든 연도에 노선이 있으면 [저장] 을 누름. 저장 중에는 화면 전체가 잠깐 잠김. +7. 다음 단계(확정 · 업체 · 내보내기)로 넘어감. + +## 단추 + +| 단추 | 하는 일 | +| --- | --- | +| 분석 | 연도 구간마다 노선 후보 넷을 찾음. 입력(지점 · 기준 · 지형)이 이전과 같으면 저장된 결과를 그대로 불러와 바로 끝남. 받은 지형 밖에 지점이 있으면 눌 수 없음 | +| 저장 | 연도별로 고른 후보를 정본으로 저장함. 끌어 옮긴 지점도 이때 함께 저장됨. 어느 연도든 노선이 없으면 눌 수 없음(마우스를 올리면 안내 문구 표시) | +| 3D | 지형과 노선을 입체로 보는 창을 엶. 노선이 하나라도 있어야 눌 수 있음 | +| 범위 밖 — 더 받기 | 지점이 받은 지형 밖일 때만 나타남. 지형(수치지형도 · 위성 · 연도별 높이 자료)을 더 받음 | + +- 산림청 소속이 아닌 사용자(지정받은 회사 등)에게는 [분석] [저장] [범위 밖 — 더 받기] 가 보이지 않으며 지점도 끌 수 없음. 보기만 가능. + +## 결과 읽는 법 + +- **평면의 선 모양** + - 굵은 선 = 연도별로 고른 후보를 이은 예정노선. + - 흰 점선(옅음) = 고르지 않은 다른 후보들. 분석 전에는 지점끼리 잇는 점선만 보임. + - 누른 측점 = 보라색 고리로 표시됨. +- **지점(마디) 색** + - 초록 = 첫 지점(시점), 빨강 = 마지막 지점(종점), 파랑 = 연도가 바뀌는 중간 지점. + - 지점 둘레에 빨간 고리와 점선 고리 = 그 지점이 받은 지형 밖이라는 뜻. 「지형」 카드에 「빨간 고리 지점이 받은 지형 밖입니다 — 더 받아야 분석합니다.」 가 나옴. +- **등고선** = 도엽(수치지형도) 등고선. 노란 점선과 노란 숫자(높이)로 보임. 지도를 확대하면 더 촘촘히 보임. +- **연도별 노선 카드** + - 윗줄 = 「연도 · 시작 측점 ~ 끝 측점 (연장 m)」. 분석 전에는 「분석 전 — [분석]을 누르세요」 가 보임. + - 후보 카드 한 장마다 「연장 · 최대 기울기(%) · 위반 개수」 를 보여 줌. 위반 = 기울기 기준을 지키지 못한 곳의 수. + - 「노선 없음」 으로 회색 처리된 카드 = 그 후보는 길을 찾지 못함. 마우스를 올리면 까닭 표시. 누를 수 없음. + - 고르지 않고 두면 네 후보 중 차례(영선 → 토공 최소 → 기울기 최소 → 비중 계산)대로 처음 성공한 후보가 자동으로 잡힘. +- **누가(측점 번호)** = 앞 연도 끝 = 다음 연도 시작으로 이어서 셈. 시작 값은 프로젝트 등록 때 정한 노선 시작 누가. 연도 경계는 꺾인 채 한 측점으로 표시됨. +- **횡단 판** = 누른 측점의 지반선 단면. 제목 줄에 「연도 · 측점 이름 · 중심 지반고」 표시. 좌우 20 m 범위의 지반만 보이며 설계선은 없음. + +## 보이는 것과 안 보이는 것 + +| 구분 | 내용 | +| --- | --- | +| 보임 | 위성 지도, 등고선, 지점, 노선 중심선, 측점 눈금과 이름, 지반선 횡단, 지형 + 중심선 3D | +| 안 보임 | 노견, 절토 · 성토선, 종단도, 배수유역, 구조물(3D 에도 없음) | +| 이 화면에 칸 없음 | 종단 기울기 상한 · 최소 곡선 반지름 등 기준값. 등록 때 정한 임도 종류와 설계속도로 자동 정해짐 | + +## 지점 끌기 + +- 지점(원) 위에서 마우스 왼쪽 단추를 누른 채 끌면 지점이 따라 움직임. 끝점은 이웃 연도의 시작점과 한 점으로 묶여 있어 함께 움직임. +- 지점을 옮기면 그 지점 앞 · 뒤 연도의 분석 결과가 비워지고 「지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요.」 가 횡단 판에 나옴. 다시 [분석] 을 눌러야 함. +- 옮긴 위치는 [저장] 전까지 이 브라우저 탭에만 임시로 남아 있음(새로 고침해도 유지, 탭을 닫으면 사라짐). [저장] 을 눌러야 정본이 바뀜. +- 손이 조금 떨리는 정도(몇 픽셀)의 움직임은 끌기로 치지 않고 단순 누름으로 처리함. +- 지도 자체는 지점이 아닌 곳을 끌어 옮기고, 휠로 확대 · 축소함. + +## 알아 둘 것 + +- [저장] 은 노선 모양을 화면에서 받지 않고, 고른 후보 이름만 서버에 보냄. 서버가 저장된 분석 결과로 노선을 다시 이어 붙여 저장하므로, [분석] 을 하지 않은 상태에서는 저장할 수 없음(「분석 저장본이 없습니다 — [분석]을 먼저 하세요.」). +- 처음 열 때 지점이 비어 있으면 「N년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.」 라는 문구만 나오고 진행이 안 됨. +- 지형 받기와 분석은 서버 뒤에서 도는 작업임. 화면을 닫았다 다시 열어도 도는 중이면 이어서 진행률을 보여 줌. +- 위성 사진은 못 받아도 지형 받기가 멈추지 않음. 위성이 없으면 어두운 바탕에 지점과 노선만 보임. +- 3D 창은 한 번에 한 연도씩 보며, 창 위쪽 선택칸으로 연도를 바꿈. 창을 닫으면 3D 화면 자원이 정리됨. +- 법 기준의 세부 수치와 사용자 확정 전 값은 「30_사용자_확정_전_값」 문서 참고. diff --git a/resources/help/D03/20_쓰인_방법과_근거.md b/resources/help/D03/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..5f6a760a7 --- /dev/null +++ b/resources/help/D03/20_쓰인_방법과_근거.md @@ -0,0 +1,52 @@ +# 쓰인 방법과 근거 + +## 노선을 찾는 방법 + +- 연도마다 그 연도의 시점과 종점 두 점 사이를 한 번씩 찾음. 연도 사이의 경계점은 옮기지 않으므로 앞 연도 끝과 다음 연도 시작이 정확히 맞물림. +- 찾는 엔진은 임도설계 「노선 추천」(B05)과 같은 엔진임. 지형 높이 자료(연도별 높이 격자)에서 경사를 읽고, 기울기 · 곡선 · 하천 이격을 지키는 길을 찾아 곡선까지 맞춘 중심선을 냄. +- 후보는 네 갈래로 같은 입력에서 각각 구함. + +| 후보 | 고르는 기준 | +| --- | --- | +| 영선 | 목표 기울기로 등고선을 비스듬히 건너가는 일정한 기울기 선. 목표 기울기를 따로 주지 않으므로 기울기 상한을 목표로 삼음 | +| 토공 최소 | 후보 길 중 절토 + 성토 + 반입 + 사토 물량이 가장 적은 길 | +| 기울기 최소 | 후보 길 중 최대 종단 기울기가 가장 작은 길(같으면 평균 기울기가 작은 길) | +| 비중 계산 | 예상 · 토공 · 기울기 세 가지 비용을 비중으로 섞어 한 번 찾은 길. 비중은 기본값 50 : 25 : 25(%) 그대로 씀 | + +- B05 의 다른 후보(예상노선 추적 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소)는 이 화면에서 쓰지 않음. +- 예상노선 추적은 두 점 사이가 직선이 되어 의미가 없어 뺐음. + +## 추천 기준(자동으로 정해지는 값) + +- 화면에 기준 칸이 없음. 프로젝트 등록 때 정한 「임도 종류」와 「설계속도」로 임도 법정 함수가 값을 냄. +- 설계속도는 종류별로 고를 수 있는 값이 정해져 있음. 작업임도는 20 km/h 하나, 간선 · 산불진화 임도는 20 · 30 · 40 km/h 중 선택. 지정하지 않으면 20 km/h. +- 지형 구분은 등록 칸이 없어 가장 완화된 쪽(특수지형)으로 고정해 씀. + +| 설계속도 | 종단 기울기 상한 | 최소 곡선 반지름 | +| --- | --- | --- | +| 20 km/h | 14 % | 12 m | +| 30 km/h | 12 % | 20 m | +| 40 km/h | 10 % | 40 m | + +- 하천 이격 = 100 m. 도엽 하천선 중 소하천 · 지방하천 · 국가하천만 대상. +- 탐색 복도 반폭 = 300 m. 두 지점을 이은 선 양옆 300 m 안에서만 길을 찾음. 100 m · 200 m 로는 기준을 못 지키는 곳이 남아 300 m 로 넓힘. +- 위반 개수 = 기울기 기준을 지키지 못한 곳의 수. 지형상 길이 없을 때만 어쩔 수 없이 기준을 넘은 길이 나옴. + +## 쓰는 지형 자료 + +- 수치지형도 도엽(축척 1/5,000)을 지점 둘레 3×3 장씩 받아 한 장으로 병합함. 이미 받은 도엽은 다시 받지 않고, 옛 도엽도 지우지 않음. +- 위성 지도(VWorld)는 배경 그림용. 이미 받은 범위가 덮으면 건너뜀. +- 연도별 높이 격자는 그 연도 구간 상자 + 탐색 복도 300 m + 여유 50 m 범위로 만듦. 지점이 격자 가장자리에서 300 m 안쪽에 있어야 「받은 지형 안」 으로 판정함. +- 도엽 등고선을 평활화한 높이 격자라 실제 측량 지형과 차이가 있을 수 있음. + +## 저장 방식 + +- 같은 입력(지점 · 기준 · 지형)이면 이전에 계산한 결과를 그대로 써서 [분석] 이 빨리 끝남. 지점이나 기준이 바뀌면 그 연도만 다시 계산함. +- [저장] 은 서버가 저장된 분석 결과에서 고른 후보를 다시 이어 정본으로 기록함. 화면에서 계산한 값을 그대로 받아 적지 않음. +- 누가 계산, 후보 고름, 중심선 잇기는 화면과 서버가 같은 규칙으로 따로 셈. 한쪽만 고치지 않도록 짝으로 관리하며 시험으로 대조함. + +## 한계 + +- 후보 네 개가 서로 비슷한 길이 될 수 있음(가파른 지형에서 기준을 지키는 길이 좁은 띠 하나뿐인 경우). +- 평면 노선만 다룸. 종단(세로 단면) · 토공량 확정 · 구조물 계획은 이 단계 범위가 아니며, 임도설계 단계에서 다룸. +- 횡단은 지반선만 보여 주며 설계 단면은 아님. diff --git a/resources/help/D03/30_사용자_확정_전_값.md b/resources/help/D03/30_사용자_확정_전_값.md new file mode 100644 index 000000000..8933630b4 --- /dev/null +++ b/resources/help/D03/30_사용자_확정_전_값.md @@ -0,0 +1,21 @@ +# 사용자 확정 전 값 + +아래 값은 코드에 기본값으로 들어 있으나 사용자가 최종 확정한 값이 아님. 바꾸려면 개발 담당에게 요청. + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 지형 구분 | 특수지형(가장 완화) 고정 | 예정노선 등록 화면에 지형 구분 칸이 없어 임도설계 기본 대체값을 그대로 씀 | 확정 전 | +| 종단 기울기 상한 | 설계속도 20 · 30 · 40 km/h 에 14 · 12 · 10 % | 임도 규정 별표 설계속도별 값 중 특수지형 열 | 지형 구분 확정에 따라 바뀜 | +| 최소 곡선 반지름 | 12 · 20 · 40 m | 같은 별표의 특수지형 열 | 지형 구분 확정에 따라 바뀜 | +| 하천 이격 | 100 m | 법정 수치 없음. 타당성 평가 별표 비고(산지계류 100 m 이내 구간 비율)를 후보로 씀 | 임도설계와 같은 기본값 | +| 탐색 복도 반폭 | 300 m | 예정노선 화면 전용값. 실제 지형으로 시험해 정함 | 시험 결과 기준 | +| 비중 계산 비중 | 예상 50 · 토공 25 · 기울기 25 (%) | 임도설계 기본 비중 | 이 화면에서 바꾸는 칸 없음 | +| 영선 목표 기울기 | 기울기 상한과 같게 | 임도설계 기본 동작 | 이 화면에서 바꾸는 칸 없음 | +| 측점 간격 | 20 m | 임도설계 측점 규칙과 같음 | 고정 | +| 횡단 표시 폭 | 좌우 각 20 m | 화면 고정값 | 고정 | +| 지형 판정 여유 | 지점이 지형 가장자리에서 300 m 안쪽 | 탐색 복도와 같은 값 | 복도 값과 함께 움직임 | + +## 준비 중 + +- 예정노선 단계에서 연도별 비중이나 목표 기울기를 직접 정하는 화면 칸은 없음. 지금은 모두 기본값으로 계산함. +- 3D 보기에 노견 · 절성토면 · 구조물을 올리는 기능은 없음. diff --git a/resources/help/D04/00_설명서.md b/resources/help/D04/00_설명서.md new file mode 100644 index 000000000..8127e7330 --- /dev/null +++ b/resources/help/D04/00_설명서.md @@ -0,0 +1,81 @@ +# 예정노선 확정 · 업체 · 내보내기 설명서 + +## 이 화면에서 하는 일 + +- 2단계(분석)에서 저장한 연도별 노선을 한눈에 확인함(연장 · 시점 · 종점 · 누가 · 확정 여부). +- 연도마다 일을 맡길 업체를 지정함. 아이슬로에 등록된 회사를 고르거나, 업체명과 메일 주소를 직접 적는 수기 지정 중 선택. 한 연도에 업체 여럿 지정 가능. +- [확정] 으로 연도 노선을 확정하고, 지정 업체에 노선 파일이 든 압축 파일(zip)을 메일로 보냄. +- [내보내기] 로 노선 파일 묶음을 내려받음. +- 업체 쪽에서는 받은 예정노선을 자기 임도 프로젝트로 연결해 설계를 이어 감(아래 「받은 업체의 임도 연결」 참고). + +## 화면 구성 + +- 가운데 = 연도 표. 프로젝트 이름이 위에 크게 나오고, 표 열은 연도 · 연장(m) · 시점(위도, 경도) · 종점(위도, 경도) · 누가(m) · 확정 · 지정 업체. +- 왼쪽 위 = 「확정할 연도」 카드. 분석 [저장] 이 된 연도마다 선택 상자가 있음. 아직 확정하지 않은 연도는 처음부터 선택되어 있음. +- 왼쪽 아래 = 단추 [내보내기] [확정] [저장]. + +## 작업 순서 + +1. 연도 표에서 연도마다 연장과 누가를 확인함. 분석을 저장하지 않은 연도는 누가 칸에 「노선 분석 [저장] 전」 이 나오며, 확정할 연도 목록에도 나타나지 않음. +2. 지정 업체 칸에서 업체를 추가함. + - 등록 회사: 「업체(등록 회사 찾기 또는 이름)」 칸에 글자를 입력하면 가입된 회사 이름이 목록으로 뜸. 목록에서 고르면 등록 회사로 지정됨. 메일 칸을 비우면 그 회사의 대표 메일이 쓰임. + - 수기 지정: 업체명과 메일 주소(@ 포함)를 둘 다 적음. 없으면 「등록 회사가 아니면 업체명과 메일이 필요합니다.」 안내가 나오고 추가되지 않음. + - [추가] 를 누르면 그 연도 칸에 업체 이름 + 메일이 표시됨. 같은 업체를 두 번 넣어도 한 번만 들어감. + - 잘못 넣은 업체는 업체 옆 [×] 로 뺌. +3. [저장] 을 누르면 지정 업체가 서버에 저장됨(「지정 업체를 저장했습니다.」). 저장 전에는 표 위에 「저장 안 된 변경이 있습니다.」 가 표시됨. +4. 「확정할 연도」 에서 확정할 연도를 고르고 [확정] 을 누름. 확인 창(「고른 연도를 확정하고 지정 업체에 노선 묶음을 메일로 보냅니다. 진행할까요?」)에서 진행을 누르면 확정과 메일 발송이 이뤄짐. 저장하지 않은 업체 변경이 있으면 자동으로 먼저 저장됨. +5. 필요하면 [내보내기] 로 파일을 내려받아 보관함. + +## 단추 + +| 단추 | 하는 일 | +| --- | --- | +| 내보내기 | 「프로젝트이름_예정노선.zip」 을 내려받음. 산림청은 모든 연도와 전체 노선을 받음. 지정받은 회사는 확정된 자기 연도만 받음 | +| 확정 | 고른 연도를 확정 처리하고 지정 업체에 메일을 보냄. 고른 연도가 없으면 「확정할 연도를 고르세요.」 안내 | +| 저장 | 연도별 지정 업체 목록을 서버에 저장함 | + +- 업체 추가와 삭제는 [저장] 을 누르기 전까지 이 브라우저 탭에만 임시로 남음. 탭을 닫으면 사라지므로 [저장] 을 누를 것. +- [저장] [확정] 은 산림청 소속 사용자만 가능. 그 밖의 사용자가 누르면 「산림청만 예정노선을 확정 · 지정할 수 있습니다.」 안내가 나옴. + +## 결과 읽는 법 + +- **확정 열** = 확정한 날짜와 시각(한국 시간). 비어 있으면 아직 확정 전. +- **지정 업체 열의 표시** + - 「Aislo」 = 아이슬로 등록 회사로 지정된 업체. + - 「메일 보냄」 = 확정 때 메일이 나갔다는 표시. 같은 업체에는 다시 확정해도 중복 발송하지 않음. + - 「연결됨」 = 그 업체가 이 연도 노선을 자기 임도 프로젝트로 가져가 연결했다는 표시. +- **[확정] 후 안내 문구** = 「확정 — 메일 sent 2 · already_sent 1」 꼴로 업체별 처리 결과를 개수로 보여 줌. + +| 결과 이름 | 뜻 | +| --- | --- | +| sent | 메일 발송 성공 | +| already_sent | 이미 보낸 업체여서 다시 보내지 않음 | +| no_email | 메일 주소가 없어 보내지 못함(등록 회사의 대표 메일이 비어 있는 경우 등) | +| failed | 메일 발송 실패 | + +## 보내지는 메일 + +- 제목: 「[예정노선 확정] 프로젝트이름 N년」. +- 본문: 프로젝트 · 사업 지역 · 연도 · 연장(km 와 m) 요약과 첨부 파일 목록, 압축을 풀면 노선 입력에 바로 올릴 수 있다는 안내 문구. +- 첨부: 그 업체의 연도 한 해 분량 zip 1개(파일 이름 「프로젝트이름_예정노선_연도.zip」). 내용은 「10_내보내기_파일_구성」 참고. + +## 받은 업체의 임도 연결 + +- 지정받은 등록 회사는 대시보드의 「받은 예정노선」 구역에서 확정된 연도를 봄. 열은 예정노선 · 연도 · 확정 · 연결 상태(연결됨 / 미연결 / 업데이트 있음) · 작업. +- [보기] = 예정노선 화면을 보기만 가능한 상태로 엶. +- [연결] = 모달에서 두 가지 중 선택. + - 「새 임도 프로젝트로」: 예정노선 이름 + 연도로 임도 프로젝트를 새로 만들고 노선 입력 단계(B03)로 이동. 예정노선의 설계속도(20 · 30 · 40 km/h)가 새 프로젝트의 설계속도 기본값으로 들어감. + - 「기존 프로젝트에 연결」: 우리 회사의 임도 프로젝트를 골라 그 프로젝트에 연결. 임도 프로젝트가 없으면 「우리 회사 임도 프로젝트가 없습니다.」 표시. +- 연결하면 그 연도 노선(shapefile 한 벌)이 노선 입력 단계의 입력 파일로 들어감. 파일을 직접 올린 것과 같은 길이라 이후 처리도 같음. 지형 자료는 업체가 따로 올려야 함. +- 이미 분석 결과가 있는 임도 프로젝트에 연결하면 기존 결과를 지워도 되는지 묻는 확인 창이 먼저 나옴. 취소하면 아무것도 바뀌지 않음. +- 임도 프로젝트 노선 입력 화면의 [예정노선에서 가져오기] 단추로도 같은 일을 할 수 있음. 지정받은 연도를 고르면 노선이 입력됨. +- 연결 시점의 노선이 복사되므로, 산림청이 뒤에 노선을 바꿔도 임도 프로젝트는 자동으로 바뀌지 않음. 대신 「업데이트 있음」 표시가 뜨며, 노선 입력 화면 위쪽 띠에서 [다시 가져오기] 로 직접 반영 여부를 결정함. +- 한 임도 프로젝트는 한 연도에만 연결됨. 다른 연도로 다시 연결하면 앞의 연결은 풀림. + +## 알아 둘 것 + +- 확정 시각은 [확정] 을 누를 때마다 새로 기록됨. 이미 메일을 보낸 업체에는 다시 보내지 않음(다시 보내는 단추는 화면에 없음). +- 확정은 분석 [저장] 이 된 연도만 가능. 저장하지 않은 연도를 억지로 확정하려 하면 「분석 [저장]이 안 된 연도입니다」 오류가 나옴. +- [저장] 은 표에 있는 모든 연도의 지정 업체를 화면 내용대로 맞춤. 업체를 모두 뺀 연도는 지정이 비워짐. +- 같은 업체 판정: 등록 회사는 회사 번호, 수기 업체는 메일 주소(대소문자 구분 없음). +- 내보내기는 서버에 저장된 노선만 사용함. 2단계에서 [저장] 하지 않은 변경은 반영되지 않음. diff --git a/resources/help/D04/10_내보내기_파일_구성.md b/resources/help/D04/10_내보내기_파일_구성.md new file mode 100644 index 000000000..edfcf1655 --- /dev/null +++ b/resources/help/D04/10_내보내기_파일_구성.md @@ -0,0 +1,64 @@ +# 내보내기 파일 구성 + +## 한눈에 + +- 내려받는 파일 이름 = 「프로젝트이름_예정노선.zip」. 이름에 쓸 수 없는 글자는 밑줄(_)로 바뀜. +- 압축 안에 연도 이름의 폴더가 연도마다 하나, 그리고 「전체」 폴더가 하나 들어 있음(전체 폴더는 산림청만 받음). +- 업체에 보내는 메일 첨부 zip 은 한 연도 분량이며, 폴더 없이 그 연도 파일들이 바로 들어 있음. + +| 위치 | 내용 | +| --- | --- | +| 연도 폴더(예: 2026) | 그해 시점에서 종점까지 중심선 한 줄(shapefile 한 벌) + 좌표표 엑셀 | +| 전체 폴더 | 모든 연도를 이은 한 줄(shapefile 한 벌, 발주처 보관용) + 모든 연도가 담긴 좌표표 엑셀 | + +## shapefile 한 벌 + +- 확장자 다섯 가지가 한 벌임: .shp(선 모양) · .shx(색인) · .dbf(속성 표) · .prj(좌표계 정의) · .cpg(글자 코드). 하나라도 빠지면 안 되므로 같이 보관할 것. +- 파일 이름 = 「대상지_지역.연장km_연도」. 예: 연장이 2.2 km 이면 「대상지_지역.2.2_2026」. 전체 노선은 연도 자리에 「전체」. +- 좌표계 = UTM-K(EPSG:5179). 프로젝트가 다른 좌표계로 작업했더라도 내보낼 때 5179 로 바뀜. +- 글자 코드 = cp949(한글 윈도우 표준). 엑셀 · 지리정보 프로그램에서 한글 칸 이름이 깨지지 않음. +- 선 모양 = 평면 중심선 한 줄(높이 값 없음), 기록 한 건. +- 양식은 2026년 임도 용화 예정노선 파일(대상지_일월.용화.2.2)과 같은 꼴임. 좌표계 정의(.prj)는 그 파일 원문을 그대로 씀. + +### 속성 표 칸 + +| 칸 이름 | 뜻 | 형식 | +| --- | --- | --- | +| 대상지 | 「사업지역.연장km」 글자(예: 지역.2.2). 20바이트(한글 10자 안팎)를 넘으면 뒤가 잘림 | 글자 20 | +| 길이 | 노선 연장, 단위 km(소수 셋째 자리) | 숫자 11.3 | +| 연도 | 사업 연도. 전체 노선은 비어 있음 | 정수 4 | +| 시점누가 | 그 선이 시작하는 측점 값(m) | 숫자 11.2 | +| 종점누가 | 그 선이 끝나는 측점 값(m) | 숫자 11.2 | + +- 대상지 · 길이 두 칸은 용화 양식과 같고, 연도 · 시점누가 · 종점누가 는 이 프로그램이 덧붙인 칸임. +- 누가 값은 2단계에서 이어 셈한 값 그대로임. 앞 연도 종점 누가 = 다음 연도 시점 누가. + +## 좌표표 엑셀 + +- 파일 이름 = 「좌표표_프로젝트이름.xlsx」, 시트 이름 「좌표표」. +- 첫 줄에 「프로젝트이름 예정노선 좌표표 (UTM-K EPSG:5179)」 제목, 둘째 줄이 열 이름, 이후 연도마다 한 줄. +- 연도 폴더의 좌표표는 그 연도 한 줄, 전체 폴더의 좌표표는 모든 연도가 줄마다 들어 있음. + +| 열 | 뜻 | +| --- | --- | +| 연도 | 사업 연도 | +| 시점 X · 시점 Y | 시작점 UTM-K 좌표(m) | +| 종점 X · 종점 Y | 끝점 UTM-K 좌표(m) | +| 시점 경도 · 시점 위도 | 시작점 위경도(도) | +| 종점 경도 · 종점 위도 | 끝점 위경도(도) | +| 시점누가(m) · 종점누가(m) | 이어 셈한 측점 값 | +| 연장(m) | 그 연도 노선 길이 | + +## 보는 사람에 따른 차이 + +| 사용자 | 받는 범위 | +| --- | --- | +| 산림청 | 분석이 저장된 모든 연도 폴더 + 전체 폴더 | +| 지정받은 등록 회사 | 확정되었고 자기 회사가 지정된 연도 폴더만. 전체 폴더 없음. 받을 연도가 없으면 「받을 수 있는 연도가 없습니다」 로 거절됨 | +| 저장된 분석이 전혀 없을 때 | 「분석 [저장]이 된 연도가 없습니다.」 로 거절됨 | + +## 알아 둘 것 + +- 파일은 서버에 저장된 노선으로만 만들며, 화면에서 아직 저장하지 않은 노선은 반영되지 않음. +- 전체 노선은 앞 연도 끝점과 다음 연도 시작점이 겹치므로 겹치는 한 점을 빼고 이어 붙임. +- 임도 프로젝트로 연결할 때는 좌표표 없이 shapefile 한 벌만 노선 입력에 들어감. diff --git a/resources/help/D04/30_사용자_확정_전_값.md b/resources/help/D04/30_사용자_확정_전_값.md new file mode 100644 index 000000000..4e12a87dd --- /dev/null +++ b/resources/help/D04/30_사용자_확정_전_값.md @@ -0,0 +1,18 @@ +# 사용자 확정 전 값 + +아래 항목은 코드의 현재 동작이며, 바꾸려면 개발 담당에게 요청. + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 내보내기 좌표계 | UTM-K(EPSG:5179) 고정 | 용화 예정노선 양식 | 양식 따름 | +| 글자 코드 | cp949 | 용화 예정노선 양식 | 양식 따름 | +| 덧붙은 속성 칸 | 연도 · 시점누가 · 종점누가 | 이 프로그램에서 추가 | 업체 쪽 요청 시 조정 가능 | +| 좌표표 엑셀 | 연도 폴더 · 전체 폴더에 포함, 선택 기능은 아님 | 요구서는 「선택」 으로 적었으나 실제로는 항상 만듦 | 확정 전 | +| 메일 재발송 | 이미 보낸 업체에는 다시 보내지 않음, 화면에 재발송 단추 없음 | 서버에 재발송 옵션은 있으나 화면에 연결되지 않음 | 준비 중 | +| 연결 시 연도 측점 | 연결한 임도 프로젝트에서 측점이 처음부터 다시 시작할지 | 이어 받기 기능이 필요해 뒤로 미룬 항목 | 준비 중 | +| 설계속도 전달 | 새 임도 프로젝트로 연결할 때 예정노선 설계속도가 노선 단계(B05) 설정에도 들어감. 기존 프로젝트는 프로젝트 설계속도 칸이 비어 있을 때만 채움 | 임도설계 설정과 같은 값 범위 | 동작 중 | + +## 준비 중 + +- 지정받은 업체에 확정 메일 외 별도 알림을 보내는 기능은 없음. +- 업체 지정 이력(누가 언제 바꿨는지)을 보여 주는 화면은 없음. diff --git a/resources/help/E01/00_설명서.md b/resources/help/E01/00_설명서.md new file mode 100644 index 000000000..0998ead39 --- /dev/null +++ b/resources/help/E01/00_설명서.md @@ -0,0 +1,55 @@ +# 안정성평가 만들기 설명서 + +## 이 화면에서 하는 일 + +- 횡단 설계(B06)까지 끝난 임도 프로젝트 하나를 골라 새 안정성평가 프로젝트를 만듦. +- 만드는 순간의 원본 자료를 안정성평가 쪽에 따로 복사해 둠(동결 복사). 그 뒤 해석은 모두 이 복사본으로 함. +- 대시보드 [+] 새 프로젝트 → 「안정성평가」를 누르면 이 화면이 열림. + +## 작업 순서 + +1. **대상 임도 프로젝트** 칸에서 원본 임도를 고름. 목록 글 = 프로젝트 이름 · 지역 · 사업 연도(최근에 고친 프로젝트가 위). +2. **프로젝트명** 칸을 확인함. 손대지 않으면 「원본 이름 안정성평가」로 자동으로 채워짐 — 원하는 이름으로 바꿔도 됨. +3. [등록]을 누름. 복사하는 동안 화면 전체가 잠기고, 끝나면 「프로젝트를 만들었습니다.」 알림이 뜸. +4. 새 프로젝트가 현재 프로젝트가 되고 첫 단계 「지반 조건」(E02) 화면으로 넘어감. + +## 단추 + +| 단추 | 하는 일 | +| --- | --- | +| [?] | 이 설명서를 엶 | +| [취소] | 만들지 않고 대시보드로 돌아감 | +| [등록] | 고른 임도를 복사해 안정성평가 프로젝트를 만듦. 고를 임도가 없으면 흐리게 꺼져 있음 | + +## 목록에 나오는 임도 + +- 우리 회사 임도 설계 프로젝트 중 **횡단 설계(B06) 단계가 끝난 것만** 나옴. +- 고를 임도가 하나도 없으면 칸 아래에 「횡단 설계(B06)까지 끝난 임도 프로젝트가 없습니다.」가 보임 — 먼저 임도 프로젝트에서 횡단 설계를 끝낼 것. +- 지역 · 도로 종류 · 사업 연도는 원본 값을 그대로 물려받음. + +## 복사하는 것 · 안 하는 것 + +해석에 쓰는 자료만 복사함(보통 수십 MB 안쪽). + +| 복사함 | 안 함 | +| --- | --- | +| 원본 프로젝트 정보 · 단계 상태 · 지표면 확정 값 | 원본 점군(LAS) 파일 | +| 최신 노선과 측점 · 종단 · 횡단 자료 | 상세 설계(B07) · 수량(B08) · 내역(B09) | +| 노선 폴더(구조물 배치 포함) · 종횡단 파일 | 쓰이지 않은 다른 지형 격자 | +| 원본 설계에 쓰인 지형 격자(DTM) 하나 | | +| 최신 노선 3D 면 · 배수 편집분 | | + +## 결과 읽는 법 + +- [등록]이 끝나면 지반 조건(E02) 화면이 열리고, 지반물성치 · 기준안전율 등은 기본값으로, 측점별 토층 두께는 복사한 B06 값으로 채워져 있음. +- 원본 횡단 파일이 없는 측점이 있으면 E02 · E03 화면 위에 **「횡단 없는 측점 N 개 — 해석에서 빠짐」** 띠가 보임. 띠를 누르면 빠진 측점의 누가거리(m)가 펼쳐짐. +- 빠진 측점은 해석 목록에 나오지 않음. 나머지 측점 해석에는 영향 없음. + +## 알아 둘 것 + +- **업데이트 없음**: 만든 뒤 원본 임도를 고쳐도 이 안정성평가는 바뀌지 않음(만든 순간의 자료로 고정). +- 원본의 바뀐 설계로 다시 평가하려면 이 화면에서 **안정성평가를 새로 만들 것**. +- 복사에 실패하면 「원본 자료 복사에 실패했습니다.」 알림이 뜨고, 만들다 만 프로젝트는 자동으로 지워짐 — 다시 [등록]하면 됨. +- 원본 임도 프로젝트에는 아무것도 쓰지 않음(읽기만 함). +- 안정성평가의 지반 값은 **문헌 추정치 · 현장 시험 없음**이 기본임 — 다음 단계(E02)에서 현장 여건에 맞게 확인 · 수정할 것. +- 횡단 없는 측점을 살리려면 원본 임도에서 그 측점 횡단을 다시 만든 뒤 안정성평가를 새로 만들 것. diff --git a/resources/help/E02/00_설명서.md b/resources/help/E02/00_설명서.md new file mode 100644 index 000000000..385338eda --- /dev/null +++ b/resources/help/E02/00_설명서.md @@ -0,0 +1,50 @@ +# 지반 조건 설명서 + +## 이 화면에서 하는 일 + +- 2D 해석(E03)에 들어갈 지반 조건을 정함 — 지반물성치 · 기준안전율 · 노면 하중 · 지진 · 지하수 · 층 두께. +- 처음 열면 기본값(문헌 추정치)과 복사한 B06 측점 값으로 채워져 있음. 칸마다 바꿀 수 있음. +- 좌측 = 안내 글 · 상태 글 · [초기화] [저장]. +- 가운데 = 카드 다섯 — 「지반물성치」 · 「기준안전율」 · 「노면 하중 · 지진」 · 「지하수」 · 「층 두께 · 구간 덮어쓰기」. + +## 작업 순서 + +1. **지반물성치** 카드에서 지층별 γt · γsat · c · φ 를 확인함. 현장 값이 있으면 고쳐 넣고, 필요하면 [지층 추가]. +2. 같은 카드 아래 **B06 지반 → 지층** 줄에서 B06 의 토사 · 리핑암 · 발파암 · 성토체가 어느 지층으로 해석될지 고름. +3. **기준안전율** 카드에서 조건(건기 · 우기 · 지진 시 · 단기)별 기준을 확인함. +4. **노면 하중 · 지진** 카드에서 노면 하중(kPa)과 지진 검토 켬/끔 · 수평지진계수 kh 를 정함. +5. **지하수** 카드에서 건기 지하수와 우기 규칙(넷 중 하나)을 고름. +6. **층 두께 · 구간 덮어쓰기** 카드에서 특정 구간만 지층 · 토층 두께를 바꿀 것이 있으면 [구간 추가]. 아래 **측점별 적용 지층** 표로 결과를 확인함. +7. 좌측 [저장]을 누름. 저장해야 2D 해석(E03)에 쓰임. + +## 단추 + +| 단추 | 자리 | 하는 일 | +| --- | --- | --- | +| [저장] | 좌측 아래 | 화면 값을 검사한 뒤 작업본으로 저장함. 틀린 칸이 있으면 저장하지 않고 알림 | +| [초기화] | 좌측 아래 | 확인 창 뒤 초기값(만들 때 기본값 + B06 측점)으로 되돌림. 저장한 값이 사라짐 | +| [?] [<] [>] | 좌측 아래 | 설명서 · 이전 단계 · 다음 단계(2D 해석) | +| [지층 추가] | 지반물성치 카드 | 「새 지층 n」 줄을 덧붙임(풍화토 기본값으로 시작) | +| [지움] | 표 줄 끝 | 그 줄을 지움. 쓰이는 지층은 지울 수 없음 | +| [기본값] | 카드마다 | 그 카드 값만 기본값으로 되돌림(다른 카드는 그대로) | +| [구간 추가] | 층 두께 카드 | 덮어쓰기 구간 한 줄(첫 측점 ~ 끝 측점)을 덧붙임 | +| [수위선 구간 추가] · [점 추가] | 지하수 카드 | 우기 규칙이 「사용자 수위선」일 때만 보임 | + +## 결과 읽는 법 + +- 칸에 마우스를 올리면 **기본값과 근거**(지식DB 07 · KDS 11 70 05 등)가 보임. 사용자가 더한 지층은 「사용자 지층 — 출처 없음」. +- 틀린 값은 칸 테두리가 **빨갛게** 되고, 마우스를 올리면 까닭(「범위 밖 (0 ~ 60)」 등)이 보임. 좌측에 「입력값을 확인하세요 — 틀린 칸 N」. +- 바꾸고 저장하지 않으면 좌측에 「저장 안 된 변경이 있습니다.」가 보임. +- **측점별 적용 지층** 표 = 측점마다 실제로 해석에 들어갈 위 지층 · 아래 지층 · 토층 두께. 구간 덮어쓰기가 걸린 줄은 바탕색이 다름. 아래 지층이 없으면 「전부 위 지층」. +- 공유받은 회사(보기만)는 칸과 단추가 꺼지고 「보기만 허용된 프로젝트입니다.」가 보임. + +## 알아 둘 것 + +- **문헌 추정치 · 현장 시험 없음**: 지반물성치 기본값은 국내 설계 편람 · 학술 자료의 대표값임. 현장 시추 · 시험 값이 아니므로 결과는 참고용 — 현장 조사 값이 있으면 꼭 바꿔 넣을 것. +- 자동저장 없음. 바꾼 값은 이 브라우저 탭에만 임시로 남고, [저장]해야 E03 [해석]에 쓰임. +- 화면 위 「횡단 없는 측점 N 개 — 해석에서 빠짐」 띠 = 원본에 횡단 파일이 없어 해석에서 빠지는 측점. 누르면 목록이 펼쳐짐. +- 측점의 B06 지반 · B06 토층 두께는 복사본 값이라 화면에서 고칠 수 없음 — 바꾸려면 구간 덮어쓰기를 쓸 것. +- 저장 뒤 E03 에서 [해석]을 다시 누르면 값이 바뀐 측점만 다시 계산함. +- 기준안전율 표의 「단기」 줄 · 「쌓기(연약지반)」 칸은 값만 보관함 — 지금 2D 해석 판정은 건기 · 우기 · 지진 시의 깎기 · 쌓기 칸만 씀. +- 우기 지하수 규칙(KDS 기본 외 갈래 포함)은 지금 E03 횡단 그림의 수위선에 반영됨. 안전율 계산은 아직 모든 측점을 「공사 뒤 지표까지 포화」로 셈 — 고른 갈래의 계산 반영은 준비 중. +- 칸 뜻 · 범위는 「칸 뜻」, 근거 · 수식은 「쓰인 방법과 근거」, 확정 안 된 값 목록은 「사용자 확정 전 값」 항목에 있음. diff --git a/resources/help/E02/10_칸_뜻.md b/resources/help/E02/10_칸_뜻.md new file mode 100644 index 000000000..872615c53 --- /dev/null +++ b/resources/help/E02/10_칸_뜻.md @@ -0,0 +1,75 @@ +# 칸 뜻 + +## 지반물성치 표 + +| 칸 | 뜻 | 넣을 수 있는 범위 | +| --- | --- | --- | +| 지층 | 지층 이름 | 1~30 자 · 같은 이름 안 됨 | +| γt (kN/m³) | 습윤 단위중량 — 물에 잠기지 않은 흙 1 m³ 의 무게 | 10 ~ 30 | +| γsat (kN/m³) | 포화 단위중량 — 물에 잠긴 흙 1 m³ 의 무게. 지하수위 아래에서 씀 | 10 ~ 30 · γt 이상 | +| c (kPa) | 점착력 — 흙 알갱이끼리 붙잡는 힘 | 0 ~ 1,000 | +| φ (°) | 내부마찰각 — 흙이 서로 비비며 버티는 정도(클수록 단단함) | 0 ~ 60 | +| 지하수 무리 | 「토사 · 풍화암」 또는 「암반」. 우기 규칙이 KDS 기본일 때 어느 수위 규칙을 쓸지 가름 | 둘 중 하나 | + +기본 지층 여섯 = 풍화토 · 풍화암 · 연암 · 경암 · 성토 · 붕적토. 값은 「사용자 확정 전 값」 항목에 있음. + +## B06 지반 → 지층 + +| 칸 | 뜻 | 기본 | +| --- | --- | --- | +| 토사 (경계 위) | B06 횡단의 암 경계 위 흙(모든 측점) | 풍화토 | +| 리핑암 (경계 아래) | B06 리핑암 측점의 암 경계 아래 | 풍화암 | +| 발파암 (경계 아래) | B06 발파암 측점의 암 경계 아래 | 연암 | +| 성토체 | 쌓기로 새로 올린 흙 | 성토 | + +## 층 두께 · 구간 덮어쓰기 + +| 칸 | 뜻 | +| --- | --- | +| 시작 (m) · 끝 (m) | 덮어쓸 누가거리 구간. 양 끝 측점 포함 · 끝은 시작보다 커야 함(0 ~ 100,000) | +| 위 지층 | 구간 안 측점의 위 지층. 「B06 그대로」 = 바꾸지 않음 | +| 아래 지층 | 구간 안 측점의 아래 지층. 「B06 그대로」 = 바꾸지 않음 | +| 토층 두께 (m) | 원지반에서 아래 지층 윗면까지 깊이. 빈 칸 = B06 그대로(0 ~ 50) | + +- 줄이 여럿 겹치면 **뒤 줄이 이김**. +- 토사 측점은 B06 토층 두께가 없어 「전부 위 지층」임. 토사 측점에 아래 지층을 넣으려면 아래 지층과 함께 토층 두께도 넣을 것. +- 쓰이는 지층(B06 지반 연결 · 구간에서 고른 지층)은 물성 표에서 지울 수 없음. + +## 측점별 적용 지층 (읽기 전용) + +| 칸 | 뜻 | +| --- | --- | +| 측점 | 측점 번호(예 `5+20`) | +| B06 지반 | 복사한 B06 횡단의 지반 구분(토사 · 리핑암 · 발파암) | +| B06 토층 두께 (m) | B06 암 경계 깊이. 토사 측점은 「-」 | +| 위 지층 · 아래 지층 · 토층 두께 (m) | 연결 · 구간 덮어쓰기를 모두 반영한 최종 값 | + +## 기준안전율 + +- 줄 = 건기 · 우기 · 지진 시 · 단기 · 칸 = 깎기 · 쌓기 · 쌓기(연약지반). +- 넣을 수 있는 범위 = 1.0 ~ 3.0. +- 지금 2D 해석 판정은 건기 · 우기 · 지진 시 줄의 깎기 · 쌓기 칸만 씀. + +## 노면 하중 · 지진 + +| 칸 | 뜻 | 기본 · 범위 | +| --- | --- | --- | +| 노면 하중 (kPa) | 노면 양 끝 사이에 고르게 얹는 차량 · 통행 하중 | 10 · 0 ~ 100 | +| 지진 검토 | 켜면 E03 에 「지진」 조건이 생김 | 끔 | +| 수평지진계수 kh | 흙 무게의 몇 배를 옆으로 미는 힘으로 볼지. 지진 검토를 켰을 때만 넣음 | 0 · 0 ~ 0.5 | + +## 지하수 + +| 칸 | 뜻 | 기본 | +| --- | --- | --- | +| 건기 지하수 | 건기 · 지진 시 수위 규칙(없음 · 지표 포화 · 비탈 높이 ½ 포화) | 없음 | +| 우기 규칙 | 넷 중 하나 — KDS 기본 · 지표 아래 깊이 = 비탈 높이 × 비율 · 간극수압비 r_u · 사용자 수위선 | KDS 기본 | +| 우기 · 토사 · 풍화암 | KDS 기본일 때, 비탈 겉 지층이 「토사 · 풍화암」 무리인 측점의 규칙 | 지표 포화 | +| 우기 · 암반 | KDS 기본일 때, 비탈 겉 지층이 「암반」 무리인 측점의 규칙 | 비탈 높이 ½ 포화 | +| 깊이 비율 | 수위 = 공사 뒤 지표에서 (비탈 높이 × 비율)만큼 아래 | 0.5 · 0 ~ 1 | +| r_u | 간극수압 = 그 자리 흙 무게 압력 × r_u | 0.2 · 0 ~ 0.9 | +| 수위선 구간 — 시작 · 끝 (m) | 수위선을 쓸 누가거리 구간. 덮지 않은 측점은 KDS 기본 규칙 | 없음 | +| 점 (offset · 깊이) | offset = 중심선에서 거리(m · 좌 +, −200 ~ 200) · 깊이 = 공사 뒤 지표 아래(m, 0 ~ 50). 점은 둘 이상 · offset 은 작은 쪽부터 커지게 | [−10 : 1] · [10 : 1] 로 시작 | + +- 비탈 겉 지층 = 토층 두께가 0 인 측점은 아래(암) 지층, 그 밖은 위 지층. +- 수위선 구간이 겹치면 뒤 줄이 이김. diff --git a/resources/help/E02/20_쓰인_방법과_근거.md b/resources/help/E02/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..eb8f3ee94 --- /dev/null +++ b/resources/help/E02/20_쓰인_방법과_근거.md @@ -0,0 +1,60 @@ +# 쓰인 방법과 근거 + +## 지반 값이 해석에 들어가는 길 + +- 비탈 안 흙덩이를 세로로 잘게 나눈 조각(절편)마다 무게와 버티는 힘을 셈(자세한 해석 방법은 E03 설명서 「쓰인 방법과 근거」). +- **무게** = 지층별 (단위중량 × 그 지층 두께)의 합 × 절편 폭 + 노면 하중. 지하수위 위는 γt, 아래는 γsat 를 씀. +- **버티는 힘**(Mohr-Coulomb 강도) = c × 바닥 길이 + (바닥을 누르는 힘 − 물이 떠받치는 힘) × tanφ. +- 물이 떠받치는 힘(간극수압) = 물 단위중량 9.81 kN/m³ × 수위에서 절편 바닥까지 깊이. 물이 차면 버티는 힘이 줄어 안전율이 낮아짐. +- **안전율** = 버티는 힘 합 ÷ 미끄러지게 하는 힘 합. 1 보다 작으면 무너지는 쪽, 기준안전율 이상이면 O.K. + +## 지층을 나누는 방법 + +- 암 경계 = 원지반 선을 토층 두께만큼 그대로 아래로 내린 선(B06 물량 가름과 같은 정의). +- 경계 위 = 위 지층(기본 풍화토) · 경계 아래 = 아래 지층(리핑암 측점 = 풍화암 · 발파암 측점 = 연암) · 쌓기 흙 = 성토. +- B06 토층 두께는 물량을 가르려고 정한 값(기본 0.5 m)이라 실제 지층 두께와 다를 수 있음 — 구간 덮어쓰기로 고칠 것. + +## 기준안전율 — KDS 11 70 05 (건설공사 비탈면 설계기준) + +| 조건 | 깎기 | 쌓기 | 쌓기(연약지반) | 지하수 · 하중 조건 | +| --- | --- | --- | --- | --- | +| 건기 | 1.5 | 1.5 | 1.3 | 비탈 안에 지하수 없음 | +| 우기 | 1.2 | 1.3 | 1.2 | 토사 · 풍화암 = 지표까지 포화 · 암반 = 비탈 높이 ½ 깊이까지 포화 | +| 지진 시 | 1.1 | 1.1 | 1.1 | 건기 수위 + 수평지진계수 kh | +| 단기 | 1.1 | 1.1 | 1.1 | 1 년 미만 · 시공 중 | + +- 근거 표 = KDS 11 70 05 표 4.3-1(쌓기 일반) · 4.3-2(연약지반 쌓기) · 4.3-3(깎기). +- 우기 깎기 1.2 = 지하수위를 정해 두고 해석하는 방식의 값(강우 침투 해석을 따로 하면 1.3). 지하수 카드의 KDS 기본 규칙과 짝을 이룸 — 다른 우기 규칙을 고르면 기준안전율도 함께 검토할 것. + +## 지하수 규칙 넷 + +1. **KDS 기본** — 비탈 겉 지층이 토사 · 풍화암이면 지표까지 포화, 암반이면 비탈 높이 ½ 깊이까지 포화(KDS 11 70 05 표 4.3-3). +2. **지표 아래 깊이 = 비탈 높이 × 비율** — 수위 = 공사 뒤 지표에서 (비탈 높이 × 비율)만큼 아래. 좌 · 우 비탈은 각자 높이로 셈. 근거 없음 · 사용자 판단(0.5 = KDS 암반 ½ 와 같은 값). +3. **간극수압비 r_u** — 수위선 없이 간극수압 = r_u × 그 자리 흙 무게 압력(γ × 깊이). 근거 없음 · 사용자 판단. +4. **사용자 수위선** — 구간마다 점(offset · 지표 아래 깊이)을 이은 선. 점 사이는 곧은 선. 덮지 않은 측점은 KDS 기본 규칙. + +- 건기 · 지진 시는 「건기 지하수」 규칙(기본 없음)을 씀. +- 지금 E03 안전율 계산은 우기를 모든 측점 「공사 뒤 지표까지 포화」로 셈 — 위 규칙은 횡단 그림의 수위선에만 반영됨(계산 반영은 준비 중). 포화는 가장 불리한 쪽 가정임. + +## 노면 하중 + +- 노면 양 끝 사이에 고르게 얹는 하중(기본 10 kPa = 1 m² 에 약 1 t). +- 근거 = 옹벽 뒤 상재하중 10 kN/m² 를 빌려 씀(지식DB 구조물 안정계산 · KDS 11 80 05). 임도 차량 하중의 고유 근거는 없음. + +## 지진 + +- 유사정적 해석 — 흙 무게 × kh 만큼 옆으로 미는 힘을 더해 셈. +- KDS 11 90 00 1.8.1 ② — 무너졌을 때 영향 범위에 주요 구조물이 없으면 적용하지 않음. 임도는 발주자와 협의할 것 · 기본 끔. +- kh 는 지반 가속도의 비율로 사용자가 넣음(지식DB 02 예시 0.05 ~ 0.15). + +## 지반물성치 기본값 근거 + +- 지식DB 07(지반물성치 원문 근거) §3 · §4 종합 대조표 — 「도로설계요령」 · 「도로교시방서」 · 서울시 「지반조사편람」(2006) · 한국지반공학회 사면안정 학술발표회 · 한국도로공사 「도로설계실무편람」(1996) · 「건설공사 비탈면 설계기준 해설」 · Hoek & Bray. +- 지식DB 04(표준 지반물성치 후보군 · 허용안전율) — 지층별 범위와 후보값. +- 칸마다 근거 문장은 칸에 마우스를 올리면 보임. + +## 한계 + +- 현장 시추 · 시험 없이 문헌 대표값을 씀 — 같은 이름의 지층도 현장마다 값이 크게 다를 수 있음. +- 측점마다 지층은 위 · 아래 두 층까지만 나눔(구간 덮어쓰기도 두 층). +- 강우 침투 해석(시간에 따른 수위 변화)은 하지 않음. diff --git a/resources/help/E02/30_사용자_확정_전_값.md b/resources/help/E02/30_사용자_확정_전_값.md new file mode 100644 index 000000000..631fd941c --- /dev/null +++ b/resources/help/E02/30_사용자_확정_전_값.md @@ -0,0 +1,58 @@ +# 사용자 확정 전 값 + +아래 값은 문헌 · 기준에서 고른 **추정 기본값**임. 현장 시험 값이 아니며, 발주처 · 설계자 확인 전 상태임. 화면에서 언제든 바꿀 수 있음. + +## 지반물성치 기본값 + +| 지층 | γt (kN/m³) | γsat (kN/m³) | c (kPa) | φ (°) | 문헌 범위(지식DB 07 §4) | 상태 | +| --- | --- | --- | --- | --- | --- | --- | +| 풍화토 | 18.0 | 19.0 | 15 | 30 | γt 18~19 · c 5~20 · φ 27~32 | 문헌 추정 · 확정 전 | +| 풍화암 | 20.0 | 21.0 | 30 | 33 | γt 19.5~21 · c 10~30 · φ 30~36 | 문헌 추정 · 확정 전 | +| 연암 | 24.0 | 24.5 | 150 | 35 | γt 22~24 · c 50~300 · φ 33~40 | 문헌 추정 · 확정 전 | +| 경암 | 26.0 | 26.0 | 300 | 40 | γt 25~27 · c 200 이상 · φ 40~45 | 문헌 추정 · 확정 전 | +| 성토 | 18.0 | 19.0 | 10 | 28 | γt 18~19 · c 0~15 · φ 28~30 | 문헌 추정 · 확정 전 | +| 붕적토 | 17.5 | 18.5 | 5 | 27 | γt 17~18 · c 0~10 · φ 25~28 | 문헌 추정 · 확정 전 | + +- 출처 문장은 칸에 마우스를 올리면 보임. + +## 지층 연결 · 두께 + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| B06 토사 → | 풍화토 | 요구서 345 6장 · 분석 345-D | 브레인 제안 · 확정 전 | +| B06 리핑암 → | 풍화암 | 같음 | 브레인 제안 · 확정 전 | +| B06 발파암 → | 연암 | 같음 | 브레인 제안 · 확정 전 | +| B06 성토체 → | 성토 | 같음 | 브레인 제안 · 확정 전 | +| 암 측점 토층 두께 | B06 암 경계 깊이(기본 0.5 m) | B06 물량 가름용 값 — 지층 조사 값 아님 | 확인 필요 | + +## 기준안전율 + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 건기 · 우기 · 지진 시 · 단기 | 「쓰인 방법과 근거」 표 | KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3 | 기준값 · 사용자 변경 가능 | +| 우기 깎기 | 1.2 | 기본값 근거 글 = 지하수위 결정 방식 1.2(강우 침투 해석 1.3) | 지식DB 04 표는 「지표 포화 1.2 · 일반 지하수위 1.3」로 풀어 적음 — 어느 해석을 따를지 확인 필요 | + +## 하중 · 지진 + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 노면 하중 | 10 kPa | 옹벽 뒤 상재하중 10 kN/m² 준용(KDS 11 80 05) · 임도 차량 하중 고유 근거 없음 | 브레인 제안 · 확정 전 | +| 지진 검토 | 끔 | KDS 11 90 00 1.8.1 ② · 임도는 발주자 협의 | 발주자 협의 필요 | +| 수평지진계수 kh | 0 | 사용자 입력 몫 | 켤 때 값 정할 것 | + +## 지하수 규칙 + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 건기 지하수 | 없음 | KDS 11 70 05 표 4.3-3 | 기준값 | +| 우기 규칙 | KDS 기본 | KDS 11 70 05 표 4.3-3 | 기준값 | +| 우기 · 토사 · 풍화암 | 지표 포화 | KDS 11 70 05 표 4.3-3 | 기준값 | +| 우기 · 암반 | 비탈 높이 ½ 포화 | KDS 11 70 05 표 4.3-3 | 기준값 | +| 깊이 비율 | 0.5 | 근거 없음(KDS 암반 ½ 와 같은 값) | 사용자 입력 | +| r_u | 0.2 | 근거 없음 | 사용자 입력 | +| 지하수 무리 | 풍화토 · 풍화암 · 성토 · 붕적토 → 「토사 · 풍화암」, 연암 · 경암 → 「암반」 | 기본값 파일 | 확정 전 | + +## 계산에 아직 안 들어간 값 + +- 우기 규칙(KDS 기본의 암반 ½ · 깊이 비율 · r_u · 사용자 수위선)은 지금 E03 횡단 그림의 수위선에만 보임. 안전율 계산은 모든 측점을 「공사 뒤 지표까지 포화」로 셈 — 계산 반영은 준비 중. +- 기준안전율 「단기」 줄 · 「쌓기(연약지반)」 칸은 2D 판정에 아직 안 쓰임. diff --git a/resources/help/E03/00_설명서.md b/resources/help/E03/00_설명서.md new file mode 100644 index 000000000..a4407ce83 --- /dev/null +++ b/resources/help/E03/00_설명서.md @@ -0,0 +1,69 @@ +# 2D 해석 설명서 + +## 이 화면에서 하는 일 + +- 모든 측점 횡단에서 비탈이 미끄러질 위험을 2D 한계평형해석으로 셈 — 측점 · 좌우 비탈 · 조건(건기 · 우기 · 지진)마다 최소 안전율과 판정(O.K · N.G). +- 깎기 비탈 · 쌓기 비탈을 따로 셈. 옹벽 · 석축 등 벽이 있는 측점은 벽을 넣어 셈. +- 「보강 검토」 탭에서 비탈 완화 · 소단 가정을 다시 해석하고, 벽의 외적 안정(활동 · 전도 · 지지력)을 검토함. +- 화면 = 좌 측점 목록 · 위 평면 위험 띠 · 가운데 횡단 · 우 결과. + +## 작업 순서 + +### 해석 + +1. 지반 조건(E02)에서 값을 확인하고 [저장]해 둠 — 저장한 값으로만 계산함. +2. 좌측 아래 [해석]을 누름. 화면이 잠기고 「해석 중 n% · 측점 k / N」 진행률이 보임. 측점 수십 개면 몇 분 걸림. +3. 끝나면 「해석이 끝났습니다.」 알림 뒤 목록 · 평면 띠 · 횡단이 채워짐. 처음 보이는 측점 = 지금 조건에서 안전율이 가장 낮은 측점. +4. 좌측 위 **조건**에서 건기 · 우기(지진 검토를 켰으면 지진도)를 고름. 목록 · 평면 띠 · 횡단이 그 조건으로 바뀜. +5. 목록 줄이나 평면 띠의 측점 근처를 눌러 측점을 고름. 가운데 위 비탈 칸에서 「좌 비탈 · 깎기」 등 비탈을 고름(처음 = 안전율 낮은 쪽). +6. 횡단의 절편이나 우측 절편표 줄을 누르면 그 절편의 힘이 자유물체도 · 그래프에 보임. +7. 보고서(E05)에 넣을 대표 단면은 목록 「대표」 칸 별(★ · ☆)을 눌러 켜고 끈 뒤 [대표 단면 저장]. +8. [3D 보기]로 노선 3D 면 위 안전율 색을 봄. + +### 보강 검토 + +1. 측점을 고르고 가운데 위 보기 칸에서 「보강 검토」를 고름. 우측이 보강 검토 판으로 바뀜. +2. **비탈 완화 · 소단 가정**: 비탈 · 기울기 1:n · 소단 폭 · 소단 높이를 넣고 [가정 해석]. 또는 [최소 완화 찾기]로 지금 조건 기준안전율을 맞추는 가장 급한 기울기를 찾음(수십 초 ~ 1 분). +3. **벽 외적 안정**: 벽 있는 측점에서만 보임. 보강 검토 탭을 열면 Coulomb · 배면 수위 0 으로 한 번 자동 검토함. 토압 · 배면 수위를 바꿔 [외적 안정 검토]. +4. 네일 · 앵커 보강 가정은 준비 중. + +## 단추 + +| 단추 · 칸 | 자리 | 하는 일 | +| --- | --- | --- | +| 조건 · 정렬 | 좌측 위 | 조건 고름 · 「측점 순」 또는 「안전율 낮은 순」 | +| N.G 만 | 좌측 위 | 지금 조건에서 N.G 인 측점만 보임 | +| [해석] | 좌측 아래 | 전 측점 해석. 입력이 같으면 「입력이 같아 다시 셀 것이 없습니다.」 · 바뀐 측점만 다시 셈 | +| [3D 보기] | 좌측 아래 | 노선 3D 면 색칠 창을 엶(해석 뒤에만 켜짐) · [닫기]로 닫음 | +| [대표 단면 저장] | 좌측 아래 | 별 켬/끔을 저장함(바꾼 것이 있을 때만 켜짐) | +| 비탈 칸 | 가운데 위 | 볼 비탈(좌 · 우 · 깎기 · 쌓기) | +| 보기 칸 | 가운데 위 | 「해석 결과」 ↔ 「보강 검토」 | +| 등안전율 지도 · 원호 중심 격자 · 다른 후보 | 가운데 위 | 횡단 그림에 켜고 끔(기본 = 지도 켬 · 격자 끔 · 후보 켬) | +| [가정 해석] · [최소 완화 찾기] | 보강 검토 | 비탈 가정 재해석 | +| [외적 안정 검토] | 보강 검토 | 벽 활동 · 전도 · 지지력 검토 | + +- 횡단 그림: 휠 = 확대 · 축소 · 가운데 단추 끌기 = 옮기기 · 두 번 누름 = 처음 배율. 마우스 아래 「거리 · EL」(offset · 표고) 표시. + +## 결과 읽는 법 + +- **O.K** = 최소 안전율 ≥ 기준안전율(E02) · **N.G** = 그보다 작음 · 「-」 = 해석 없음 · 「오류」 = 그 측점 계산 실패(줄에 마우스를 올리면 까닭). +- 판정에 쓰는 안전율 = Bishop 간편법 최소값(원호 · 비원호 중 작은 쪽). 측점 판정 = 그 조건 좌 · 우 비탈 중 하나라도 N.G 면 N.G. +- **좌측 목록**: 측점 · 좌 · 우(깎 · 쌓) · 조건별 최소 안전율 · 판정 · 대표. ▦ = 벽 있는 측점. +- **평면 위험 띠**: 노선을 지금 조건 판정 색으로 칠함(초록 O.K · 빨강 N.G · 회색 해석 없음). 오른쪽 위에 판정별 측점 수. 고른 측점은 동그라미. +- **횡단 색(등안전율)**: 원호 중심 자리마다 최소 안전율을 기준안전율 대비 일곱 띠로 칠함(밝은 화면 기준) — 진한 빨강(기준의 0.8 배 미만) · 빨강(0.8 ~ 0.9) · 분홍(0.9 ~ 1.0) · 연회색(1.0 ~ 1.1) · 연파랑(1.1 ~ 1.25) · 파랑(1.25 ~ 1.5) · 진파랑(1.5 배 이상). 범례에는 실제 안전율 값으로 적힘. +- **횡단 선**: 굵은 선 = 임계 활동면(Fs 값 · 원호 중심 Xc · Zc · 반경 R) · 옅은 선 = 다른 방법 · 다른 꼴 후보 · 점선 = 지하수위 · 노면 위 눈금 = 노면 하중 · 회색 빗금 = 벽. +- **우측 결과**: 최소 안전율 · 판정 · 기준안전율 · 방법 · 수렴 · 비탈 높이 · 원호 중심 · 반경 · 방법별 최소 안전율 표(Fellenius · Bishop · Janbu 보정 · Spencer) · 절편표 · 절편 자유물체도 · 절편력 그래프. 칸 뜻은 「칸 뜻」 항목. +- **3D 보기**: 노선 면을 가장 가까운 측점의 (최소 안전율 ÷ 기준안전율) 띠 색으로 칠함 · 해석 없는 측점은 회색. 표시만 함. +- **대표 단면 별**: 채운 별 ★ = 켬 · 밑줄 = 사용자가 바꿈. 자동으로 켜지는 측점 = 쌓기 높이 10 m 이상 · 깎기 높이 15 m 이상 · 최소 안전율 하위 5 곳. +- 좌측 상태 글: 「해석 시각」 · 「지반 조건이 바뀌었습니다 — [해석]을 다시 누르세요」(보이는 결과는 옛 결과) · 「대표 단면 바뀜 — 저장 안 됨」. + +## 알아 둘 것 + +- **문헌 추정치 · 현장 시험 없음**: 지반 값이 문헌 대표값이라 안전율은 설계 검토용 참고값임. 현장 조사 값으로 바꿔 다시 해석할 것. +- 해석은 서버가 함 · 화면은 결과만 보임. 같은 입력이면 다시 셈하지 않음. +- 우기 계산 = 지금은 모든 측점 「공사 뒤 지표까지 포화」. E02 에서 고른 우기 규칙은 횡단 그림의 지하수위 점선에만 반영됨(계산 반영은 준비 중). r_u 를 골랐으면 그림 아래에 「지하수 = 간극수압비 r_u …」 글만 보임. +- 탐색 범위 = 노면 반대쪽 끝 ~ 비탈 끝 바깥 (5 m 와 비탈 높이 2 배 중 큰 값)까지 — 설계 비탈을 지나는 면만 봄. 노선 밖 자연 비탈 전체 붕괴는 광역 위험(E07) 몫. +- 깊이 0.3 m 미만으로 겉만 긁는 면은 붕괴로 보지 않음. +- 벽은 아주 단단한 물체로 넣어 활동면이 벽을 지나지 않음 — 벽 자체 안정은 보강 검토의 「벽 외적 안정」으로 봄. +- 보강 가정은 따로 저장되는 검토용 결과임 — 설계 · 원본 해석 결과 · 임도 프로젝트는 바뀌지 않음. 먼저 지금 입력으로 [해석]이 끝나 있어야 함. 벽이 선 비탈은 가정 해석 대신 벽 외적 안정으로 검토할 것. +- 공유받은 회사(보기만)는 [해석] · 별 · 보강 단추가 꺼짐. diff --git a/resources/help/E03/10_칸_뜻.md b/resources/help/E03/10_칸_뜻.md new file mode 100644 index 000000000..7f7ba573c --- /dev/null +++ b/resources/help/E03/10_칸_뜻.md @@ -0,0 +1,73 @@ +# 칸 뜻 + +## 좌측 측점 목록 + +| 칸 | 뜻 | +| --- | --- | +| 측점 | 측점 번호 · ▦ = 벽(옹벽 · 석축 등) 있는 측점 | +| 좌 · 우 | 좌 비탈 · 우 비탈 역할(깎 = 깎기 · 쌓 = 쌓기) | +| 건기 · 우기 · 지진 | 조건별 그 측점 최소 안전율(좌 · 우 중 작은 값 · Bishop) | +| 판정 | 지금 조건의 O.K · N.G · 「-」(해석 없음) · 「오류」 | +| 대표 | 보고서 대표 단면 별. 마우스를 올리면 자동으로 켜진 까닭(쌓기 10 m 이상 · 깎기 15 m 이상 · 최소 안전율 하위 5 · 사용자 지정) | + +## 우측 결과 + +| 칸 | 뜻 | +| --- | --- | +| 최소 안전율 | 고른 비탈 · 조건에서 Bishop 간편법으로 찾은 가장 낮은 안전율(소수 셋째 자리) | +| 판정 | 최소 안전율 ≥ 기준안전율이면 O.K | +| 기준안전율 | E02 기준안전율 표의 그 조건 · 깎기 또는 쌓기 칸 | +| 방법 | 「Bishop · 원호」 또는 「Bishop · 비원호」 — 임계면 꼴 | +| 수렴 | 반복 계산이 답에 닿았는지(예 · 아니오)와 반복 횟수 | +| 비탈 높이 | 노면 끝에서 비탈 끝(원지반과 만나는 점)까지 높이차(m) | +| 벽 있음 | 이 측점 단면에 넣은 벽 수 | +| 원호 중심 · 반경 | 임계 원호의 중심(offset, 표고)과 반지름(m). 비원호면 안 보임 | +| 방법별 표 | Fellenius · Bishop · Janbu 보정 · Spencer 각각의 최소 안전율(원호 · 비원호 중 작은 값) | + +## 절편표 + +임계 활동면 위 흙을 세로로 나눈 조각(절편)마다 한 줄. 줄을 누르면 그 절편을 고름. + +| 칸 | 뜻 · 단위 | +| --- | --- | +| # | 절편 번호 | +| 폭 b | 절편 가로 폭(m) | +| 높이 h | 절편 가운데 높이(m) | +| α° | 절편 바닥 기울기(°) | +| W+Q | 절편 무게 + 노면 하중(kN/m — 단면 1 m 폭당) | +| u | 바닥 간극수압(kPa) | +| N' | 바닥을 누르는 유효 수직력(kN/m) — 물이 떠받치는 몫을 뺀 힘 | +| S | 바닥에서 동원된 전단력 = 버틸 수 있는 힘 ÷ 안전율(kN/m) | +| c · φ° | 바닥이 지나는 지층의 점착력(kPa) · 내부마찰각(°) | + +## 절편 자유물체도 · 절편력 그래프 + +- 자유물체도 = 고른 절편 하나를 크게 그리고 힘을 화살로 보임. 화살 길이 = 힘 크기 비례(kN/m). +- 화살 이름: W = 무게 · Q = 노면 하중 · N = 유효 수직력 · U = 바닥 수압 · S = 전단 저항 · EL · ER = 양옆 수평력 · XL · XR = 양옆 연직력(값이 있을 때만). +- 그래프 = 절편마다 바닥의 유효 수직응력 σ' · 간극수압 u · 동원 전단응력 τ(kPa 한 축). 가로 = 거리(offset). 기둥에 마우스를 올리면 세 값이 보임. + +## 보강 검토 — 비탈 완화 · 소단 가정 + +| 칸 | 뜻 | +| --- | --- | +| 비탈 | 가정을 넣을 비탈(좌 · 우 · 깎기 · 쌓기) | +| 기울기 1:n | 비탈 전체를 바꿀 기울기 n(세로 1 에 가로 n). 빈 칸 = 지금 값 · 0.1 ~ 4 | +| 소단 폭 m | 소단 한 칸 폭(0 ~ 10 m) | +| 소단 높이 m 마다 | 몇 m 높이마다 소단을 둘지(0 ~ 30 m). 폭 · 높이 둘 다 넣어야 소단이 생김 | +| 결과 글 | 「1:지금 → 1:가정 · 소단 폭 / 높이 · 찾은 결과(회) · 걸린 초」 | +| 결과 표 | 조건마다 전 Fs · 후 Fs · 기준안전율 · 전 판정 · 후 판정 | +| 가정 단면 | 바뀐 비탈과 그 임계면 그림(지금 조건) | + +- [최소 완화 찾기] 목표 = 지금 조건 기준안전율. 지금 기울기부터 성긴 걸음으로 완만하게 하며 처음 맞는 기울기를 찾고, 그 앞 칸 사이를 반씩 좁혀 0.05 단위로 맞춤. 비탈이 급한 자연 사면에서는 완만하게 할수록 깎기 높이가 커져 오히려 안전율이 낮아질 수도 있음. 못 찾으면 「못 찾음 — 원지반을 만나는 가장 완만한 1:n 까지 봄」. + +## 보강 검토 — 벽 외적 안정 (KDS 11 80 05) + +| 칸 | 뜻 | +| --- | --- | +| 토압 | 벽 뒤 흙이 미는 힘 계산법 — Coulomb(기본) · Rankine | +| 배면 수위 ÷ 벽 높이 | 벽 뒤 물 높이 비율(0 = 물 없음 · 1 = 벽 꼭대기까지) | +| 머리 글 | 벽 이름 · 평상시 또는 지진 시 · H(벽 높이 m) · B(바닥 폭 m) · Ka(주동토압계수) · KAE(지진 시 토압계수) · e(바닥 합력이 가운데서 벗어난 거리 m) · q_max(최대 지반 반력 kPa) | +| 활동 · 전도 · 지지력 | 줄마다 안전율 · 기준 · 판정 | + +- 기준(KDS 11 80 05): 평상시 활동 1.5 · 전도 2.0 · 지지력 3.0 · 지진 시 활동 1.2 · 전도 1.5 · 지지력 2.0. +- 지진 시 줄은 E02 에서 지진 검토를 켜고 kh 가 0 보다 클 때만 나옴. diff --git a/resources/help/E03/20_쓰인_방법과_근거.md b/resources/help/E03/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..b3d132a89 --- /dev/null +++ b/resources/help/E03/20_쓰인_방법과_근거.md @@ -0,0 +1,81 @@ +# 쓰인 방법과 근거 + +## 한계평형해석 — 절편법 + +- 가상의 미끄럼면(활동면) 위 흙덩이를 세로 조각(절편) 50 개로 나눔. +- 절편마다 무게 W(지층별 단위중량 × 두께 × 폭 + 노면 하중) · 바닥 수압 u · 바닥 강도 c · φ 를 셈. +- **안전율 Fs** = 버티는 힘(또는 모멘트) 합 ÷ 미끄러지게 하는 힘(또는 모멘트) 합. 1 미만 = 무너지는 쪽. +- 버티는 힘 = c × 바닥 길이 + (바닥을 누르는 힘 − 수압 × 바닥 길이) × tanφ (Mohr-Coulomb). +- 수압 u = 9.81 kN/m³ × 지하수위에서 절편 바닥까지 깊이. 지하수위 아래 흙 무게는 γsat 로 셈. +- 근거 = 지식DB 02(토사 · 복합 비탈면 한계평형해석 수식 명세) §1 · §2. + +## 네 가지 방법 + +| 방법 | 화면 이름 | 원리(쉬운 말) | 쓰임 | +| --- | --- | --- | --- | +| Bishop 간편법 | Bishop | 절편 옆 수평력은 넣고 연직력은 뺌 · 원 중심 둘레 모멘트로 평형 | **기본 · 판정** | +| Janbu 간편법 + 보정 | Janbu 보정 | 수평 · 연직 힘 평형만으로 셈 → 면 모양 보정계수 f0 를 곱함 | 비원호(꺾인 면) 탐색 · 비교 | +| Fellenius 법 | Fellenius | 절편 사이 힘을 모두 뺌 · 반복 없이 한 번에 셈 · 보통 낮게(보수적으로) 나옴 | 비교 | +| Spencer 법 | Spencer | 모멘트 · 힘 평형을 함께 맞춤(절편 사이 힘 기울기 λ 를 함께 찾음) | 비원호 탐색 · 비교 | + +- 이 밖의 방법(GLE · Morgenstern-Price 등)은 쓰지 않음. +- **Bishop 식 말풀이**: Fs = Σ[(c × 폭 + (W − u × 폭) × tanφ) ÷ mα] ÷ Σ(W × sinα + 지진 항). mα = cosα + sinα × tanφ ÷ Fs. 양쪽에 Fs 가 있어 맞을 때까지 되풀이해 셈. +- 진출부가 급해 어느 절편의 mα 가 0.2 보다 작아지는 면은 값이 터지므로 버림(상용 프로그램 관례). +- **Janbu 보정계수** f0 = 1 + b1 × (d/L − 1.4 × (d/L)²). L = 면 양 끝을 이은 직선 길이 · d = 그 직선에서 면까지 가장 깊은 거리 · b1 = 0.31(c 가 모두 0) · 0.69(φ 가 모두 0) · 0.50(그 밖). +- **지진**: 절편마다 무게 × kh 만큼 옆으로 미는 힘을 더함(유사정적). + +## 임계 활동면 찾기 + +- **원호** — 두 방식을 함께 씀. +- Entry & Exit: 비탈 위 · 아래 진입 · 진출 점 25 개씩을 여러 각도의 원호로 이음. +- Grid & Radius: 원호 중심 격자 15 × 15 자리마다 반지름 10 가지. +- 위 후보를 절편 30 개로 빠르게 거른 뒤, 안전율 낮은 30 개를 출발점으로 차분진화(무리 최적화)로 다듬어 절편 50 개로 최종 셈. +- **비원호** — 꼭짓점 4 개 꺾은선. 출발점 = 가장 낮은 원호 + 블록 모양 면. Janbu · Spencer 로 꼭짓점을 옮겨 가며 최소를 찾고, 찾은 면에서 Fellenius · Bishop 값도 셈. +- 방법마다 원호 · 비원호 중 작은 값이 그 방법의 최소 안전율 · 판정은 Bishop 최소 안전율. +- 같은 입력이면 늘 같은 답이 나오도록 무작위 씨앗을 고정함. +- **등안전율 지도** = 원호 중심 격자 자리마다 그 중심으로 그린 원호 중 Bishop 최소 안전율. +- 깊이 0.3 m 미만으로 겉만 긁는 면은 뺌(표토 벗겨짐은 붕괴로 보지 않음). +- 근거 = 지식DB 02 §3(Grid & Radius · Entry & Exit). 상용 대응 = SLOPE/W · Slide 의 같은 이름 방식. + +## 조건 · 비탈 + +| 조건 | 지하수 · 하중 | 기준안전율(E02 기본 · 깎기 / 쌓기) | +| --- | --- | --- | +| 건기 | 지하수 없음 | 1.5 / 1.5 | +| 우기 | 지금은 모든 측점 공사 뒤 지표까지 포화 | 1.2 / 1.3 | +| 지진(E02 에서 켰을 때) | 건기 수위 + 수평지진계수 kh | 1.1 / 1.1 | + +- 기준 = KDS 11 70 05 표 4.3-1 · 4.3-2 · 4.3-3(지식DB 04 §2). 값은 E02 에서 바꿀 수 있음. +- 좌 · 우 비탈을 따로 셈 — 깎기 = 산 쪽에서 노면 쪽으로 미끄러짐 · 쌓기 = 노면에서 바깥쪽으로 미끄러짐. +- 탐색 범위 = 노면 반대쪽 끝 ~ 비탈 끝 바깥 (5 m 와 비탈 높이 2 배 중 큰 값). +- 노면 하중 = 노면 양 끝 사이 고른 하중(E02 · 기본 10 kPa). +- 판정 = Bishop 최소 안전율 ≥ 그 조건 · 그 비탈(깎기 · 쌓기) 기준안전율이면 O.K. + +## 벽 넣기 + +- B06 에 놓인 옹벽 · 석축 · 돌쌓기 · 관 기슭막이 단면을 B06 화면과 같은 계산으로 그려 넣음. +- 벽 = 단위중량 23 kN/m³ · c 1,000 kPa · φ 45° 의 아주 단단한 물체 — 활동면이 벽을 지나지 않고 벽 뒤 · 아래로 돎. + +## 보강 검토 + +- **비탈 완화 · 소단** — 비탈 시작점(깎기 = 측구 바깥 · 쌓기 = 노면 끝)부터 1:n 기울기(+ 높이마다 소단)로 원지반을 만날 때까지 다시 그려 같은 방법 · 조건으로 다시 해석함. 2 단 경사(암 · 토사)는 한 기울기로 갈음. +- **벽 외적 안정**(KDS 11 80 05 · 지식DB 04 §2) — 토압 = Coulomb 또는 Rankine · 지진 시 = Mononobe-Okabe + 벽 관성 kh × 벽 무게. +- 활동 안전율 = (바닥 연직력 × 마찰계수 + 바닥 점착 × 폭) ÷ 수평력 합(앞쪽 수동토압 뺌). +- 전도 안전율 = 앞굽 둘레 버티는 모멘트 ÷ 넘기는 모멘트. +- 지지력 안전율 = Meyerhof 극한 지지력(유효 폭 · 경사 보정) ÷ 최대 지반 반력. +- 기준 = 평상시 활동 1.5 · 전도 2.0 · 지지력 3.0 · 지진 시 1.2 · 1.5 · 2.0. +- 가정: 벽 뒤 흙 점착 0(안전 쪽) · 벽 단위중량 23 kN/m³ · 벽 뒤 상재하중 = 쌓기 쪽 벽만 노면 하중 · 수압 = 배면 수위만큼 정수압 + 바닥 양압력. + +## 검증 + +- 상용 프로그램 검증 예제 12 문제(40 개 값)와 비교해 차이 2.2 % 안. +- 벽 외적 안정은 교과서 손계산과 비교해 차이 2 % 안. + +## 가정 · 한계 + +- 지반 값 = 문헌 추정치 · 현장 시험 없음. +- 2D 해석이라 비탈 양옆이 붙잡아 주는 효과(끝단 효과)는 빠짐 — 3D 해석(E04) 몫. +- 우기는 지표까지 포화로 셈(가장 불리한 쪽). E02 우기 규칙 갈래(암반 ½ · 깊이 비율 · r_u · 수위선)의 계산 반영은 준비 중. +- 강우 침투 해석(시간에 따른 수위 변화) · 인장균열은 넣지 않음. +- 암반 절리를 따라 미끄러지는 평면 · 쐐기 파괴는 암반 해석(E06) 몫. +- 네일 · 앵커 보강 가정은 준비 중. diff --git a/resources/help/E04/00_설명서.md b/resources/help/E04/00_설명서.md new file mode 100644 index 000000000..43ae4e0e2 --- /dev/null +++ b/resources/help/E04/00_설명서.md @@ -0,0 +1,68 @@ +# 3D 해석 설명서 + +## 이 화면에서 하는 일 + +- 노선 한 구간(보통 약 100 m)을 골라 지형 전체를 입체로 보고 가장 위험한 3차원 활동면(흙이 미끄러져 내리는 바닥면)을 찾음. +- 땅을 1 m 기둥 격자로 나눠 셀마다 「그 셀을 지나는 활동면 중 가장 낮은 안전율」을 색 지도로 보여 줌. +- 가장 위험한 활동면의 안전율 · 무너질 흙의 양(임계 체적) · 같은 구간 2D(E03) 결과와의 비를 오른쪽에 보여 줌. +- 계산은 모두 서버가 함. 화면은 결과를 그리기만 함. + +## 작업 순서 + +1. 왼쪽 **구간** 카드 목록에서 구간 하나를 누름(예: `0 – 100 m`). 직접 정하려면 아래 **시작 (m)** · **끝 (m)** 칸에 숫자를 넣음. +2. **탐색 설정** 카드에서 **조건**(건기 · 우기)과 **활동면 모양**을 고름. 처음에는 기본값 그대로 써도 됨. +3. 필요하면 **고급 설정** 카드를 펼쳐 기둥 크기 · 구 중심 격자 · 부피 범위를 바꿈(칸 뜻은 「칸 뜻」 문서). +4. 왼쪽 아래 **[해석]** 을 누름. 진행 막대에 「해석 중 n% · 합성 지형 → 거친 탐색 → 고운 탐색 → 타원체」 차례가 보임. +5. 끝나면 「3D 해석이 끝났습니다.」 알림과 함께 가운데 지도에 색이 칠해지고 오른쪽 **구간 결과** 가 채워짐. +6. 가운데 위 **[3D 보기]** 로 바꿔 입체 지형 위의 임계 활동면을 확인함. +7. 다른 구간도 같은 방법으로 해석함. 해석한 구간은 목록에 색 점과 `Fs` 값이 붙음. +8. 보고서에 넣으려면 E05 보고서 화면에서 **[보고서 갱신]** 을 누름(3D요약 시트에 구간마다 한 줄). + +## 단추 + +| 자리 | 단추 | 하는 일 | +| --- | --- | --- | +| 왼쪽 아래 | **[해석]** | 고른 구간을 해석함. 입력(지반 조건 · 탐색 설정 · 구간)이 저장된 결과와 같으면 다시 셈하지 않고 「입력이 같아 저장된 결과를 엽니다.」 | +| 왼쪽 아래 | **[다시 해석]** | 입력이 같아도 처음부터 다시 셈 | +| 진행 막대 옆 | **[끊기]** | 돌고 있는 해석을 멈춤. 전에 저장된 결과는 그대로 남음 | +| 고급 설정 카드 | **[기본값]** | 탐색 설정 칸을 기본값으로 되돌림(구간 시작 · 끝은 그대로 · 조건은 건기) | +| 가운데 위 | **[평면 지도]** · **[3D 보기]** | 위에서 본 색 지도와 입체 보기를 바꿈 | +| 가운데 위 | **[최소 안전율]** · **[임계 체적]** | 지도 · 3D 색칠 기준을 바꿈 | +| 3D 보기 위 | **임계 활동면** 체크 | 켬 = 활동체를 걷어낸 지표 + 붉은 활동면 · 끔 = 원래 지표만 | + +- 평면 지도: 마우스 휠 = 확대 · 축소, 끌기 = 옮김, 두 번 누름 = 화면 맞춤, 셀 위에 올리면 그 셀의 최소 안전율 · 임계 체적 · 가까운 측점이 뜸. +- 3D 보기: 끌기 = 돌림, 휠 = 확대 · 축소. 오른쪽 위 나침반이 방위를 알려 줌. + +## 결과 읽는 법 + +- **평면 지도**: 셀 색 = 그 셀을 지나는 활동면 중 가장 낮은 안전율. 붉을수록 위험. 점선 윤곽 = 구간 전체에서 가장 위험한 활동면이 땅을 자르는 범위, 동그라미와 `Fs` 글 = 그 활동면 자리. 굵은 선 = 고른 구간. +- **안전율 색 단계**: 1.0 미만 · 1.0~1.2 · 1.2~1.3 · 1.3~1.5 · 1.5~2.0 · 2.0~3.0 · 3.0 이상 · 회색 = 값 없음(활동면이 닿지 않은 셀). 1.3(우기) · 1.5(건기)가 기준 경계. +- **임계 체적 색 단계**: 10 m³ 미만 · 10~100 · 100~1,000 · 1,000~10,000 · 10,000 m³ 이상 — 짙을수록 큰 붕괴. +- **3D 보기**: 지표 색 = 지금 고른 층(안전율 또는 체적) · 파란 관 = 노선 중심선 · 짙은 붉은 면 = 임계 활동면(지표 아래 부분만). +- **오른쪽 구간 결과**: + +| 줄 | 뜻 | +| --- | --- | +| 3D 최소 안전율 | 구간에서 찾은 가장 낮은 3D 안전율 | +| 판정 | 「만족」 또는 「불만족」 · 괄호 안 = 조건과 기준(예: 건기 ≥ 1.50) | +| 2D 최소 안전율 (같은 구간) | E03 측점 결과 중 이 구간 안 가장 낮은 값 · 없으면 「2D 결과 없음」 | +| 3D / 2D 비 | 3D 최소 ÷ 2D 최소 · 1 보다 크면 2D 가 안전 쪽(보수적) | +| 임계 체적 | 가장 위험한 활동면 위 흙의 부피(m³) | +| 다스린 방법 | 판정에 쓴 방법(3D Bishop 또는 3D Janbu 보정) | +| 3D Bishop · 3D Janbu 보정 · Hovland (비교) | 같은 활동면을 세 방법으로 셈한 값 | +| 위치 (가까운 측점) | 가장 위험한 활동면에 가장 가까운 측점 | +| 셀 수 · 시험 활동면 수 · 계산 시간 | 계산 규모 | + +## 알아 둘 것 + +- **문헌 추정치 · 현장 시험 없음** — 지반 물성치는 문헌 · 기준값을 적용한 추정치이며 현장 조사 · 시험 성과를 반영하지 않았음. 안전율은 비교 · 검토용으로 볼 것. +- 결과는 서버에 구간마다 자동 보관됨(따로 [저장] 없음). 탐색 설정 칸 값은 이 브라우저 탭에만 기억됨. +- E02 지반 조건을 바꾸면 그 구간 결과 아래 「입력(지반 조건 · 탐색 설정 · 엔진)이 바뀌었습니다 — 다시 해석하세요.」 경고가 뜨고 목록에 「옛 결과」 가 붙음. **[해석]** 을 다시 누를 것. +- 3D 는 E02 에서 **[저장]** 한 지반 조건을 씀. 해석 전 E02 에서 [저장] 을 한 번 눌러 둘 것. +- 한 번에 해석할 수 있는 구간은 500 m 이하. 구간 안 측점이 둘 이상이어야 함. +- 해석 하나에 수십 초 ~ 수 분 걸림(구간 길이 · 탐색 폭 · 기둥 크기에 따라). 한 프로젝트에서 한 번에 하나만 돎. +- 「조건에 맞는 활동면이 없습니다 — 부피 · 높이 범위를 넓히세요.」 가 뜨면 고급 설정의 최소 · 최대 부피나 중심 높이 범위를 넓힐 것. +- 우기는 「지표까지 포화」 한 가지로 셈 — E02 의 지하수 규칙(깊이 비율 · r_u · 수위선)은 3D 에 쓰지 않음. 지진 조건 · 옹벽 · 석축은 3D 에 넣지 않음. +- 2D 대조 값은 E03 측점마다 기준 대비 가장 낮은 조건 · 비탈의 안전율이라 조건을 맞추지 않음. 같은 조건끼리 견준 표는 E05 보고서 「3D요약」 시트에 있음. +- 보기만 허용된 프로젝트에서는 「보기만 허용된 프로젝트입니다.」 가 뜨고 [해석] 단추가 꺼짐. +- 쓰인 방법 · 근거 · 한계는 「쓰인 방법과 근거」 문서, 아직 사용자 확정 전인 값은 「사용자 확정 전 값」 문서 참고. diff --git a/resources/help/E04/10_칸_뜻.md b/resources/help/E04/10_칸_뜻.md new file mode 100644 index 000000000..b89c0e2c4 --- /dev/null +++ b/resources/help/E04/10_칸_뜻.md @@ -0,0 +1,51 @@ +# 3D 해석 칸 뜻 + +## 구간 카드 + +| 칸 | 뜻 | 기본값 · 범위 | +| --- | --- | --- | +| 구간 목록 | 측점을 앞에서부터 100 m 이상씩 묶은 자동 구간 · 경계는 측점 · 끝에 50 m 미만이 남으면 앞 구간에 붙임 | 자동 | +| 시작 (m) | 해석 구간 시작 누가거리 | 첫 구간 시작 | +| 끝 (m) | 해석 구간 끝 누가거리 | 시작보다 커야 함 · 구간 길이 500 m 이하 | + +- 목록 항목 = `시작 – 끝 m` · 색 점(해석한 구간의 최소 안전율 색) · `Fs 0.00` · 입력이 바뀌었으면 「옛 결과」. +- 시작 · 끝을 직접 넣어 해석한 구간도 목록 끝에 더해짐. + +## 탐색 설정 카드 + +| 칸 | 뜻 | 기본값 · 범위 | +| --- | --- | --- | +| 조건 | 건기 = 지하수 없음 · 우기 (지표까지 포화) = 지하수위면을 지표에 둠 | 건기 | +| 활동면 모양 | 구 = 공 모양 면만 · 구 + 타원체 = 공 모양으로 찾은 위험 자리에서 옆으로 늘린 타원체도 봄 | 구 + 타원체 | +| 타원체 길이비 | 타원체를 활동 방향(비탈 아래쪽)에 직각인 옆 방향으로 몇 배 늘릴지 | 2.0 · 1.1~5.0 | +| 탐색 폭 (중심선 ±m) | 활동면 중심을 찾는 범위 = 노선 중심선 좌우 이 거리 안 | 30 m · 5~100 m | + +## 고급 설정 카드(접힘) + +| 칸 | 뜻 | 기본값 · 범위 | +| --- | --- | --- | +| 기둥 크기 (m) | 땅을 나누는 정사각 기둥(셀) 한 변 · 작을수록 정밀하나 느림 | 1.0 m · 0.5~5 m | +| 구 중심 간격 (m) | 가장 고운 탐색에서 구 중심끼리 수평 간격 | 2.0 m · 0.5~10 m | +| 중심 높이 최소 (m) | 구 중심을 지표 위 몇 m 부터 둘지 | 2 m · 0~100 m | +| 중심 높이 최대 (m) | 구 중심을 지표 위 몇 m 까지 둘지 | 30 m · 1~200 m | +| 중심 높이 간격 (m) | 중심 높이를 몇 m 씩 올려 볼지 | 2 m · 0.5~20 m | +| 반지름 증분 (m) | 구 반지름을 몇 m 씩 키워 볼지 | 1.0 m · 0.2~5 m | +| 최대 깊이 (m) | 반지름 상한 = 중심 높이 + 이 값 · 활동면이 땅속 얼마나 깊이 갈 수 있는지 | 15 m · 1~60 m | +| 최소 부피 (m³) | 이보다 작은 활동체는 셈에서 뺌(아주 얕은 표면 긁힘 제외) | 10 m³ · 0 이상 | +| 최대 부피 (m³) | 이보다 큰 활동체는 셈에서 뺌 · 그 중심에서 더 큰 반지름도 그만 봄 | 10,000 m³ · 최소보다 커야 함 | + +- **[기본값]** = 위 기본값으로 되돌림(시작 · 끝 그대로 · 조건 건기). +- 최소가 최대보다 크면 「탐색 범위의 최소가 최대보다 큽니다.」 로 거절됨. +- 칸 값은 이 브라우저 탭에 바로 기억됨(서버에는 [해석] 때 결과와 함께 남음). + +## 화면에 안 나오는 고정값 + +| 값 | 뜻 | 지금 값 | +| --- | --- | --- | +| 거친 탐색 배수 | 처음엔 구 중심 간격의 4 배로 성기게 보고, 위험한 곳 둘레만 절반씩 좁혀 감 | 4 배 · 고운 단계 4 번까지 | +| 셀 안 표본 | 셀마다 2 × 2 점에서 활동면 높이를 봐 가장자리 셀을 부분 기둥으로 셈 | 2 × 2 | +| 고운 탐색 대상 | 안전율이 2.0 또는 지금까지 최소의 1.2 배보다 낮은 자리만 더 촘촘히 · 타원체도 그 자리만 | 2.0 · 1.2 배 | +| Janbu 보정 전환 | 활동면 바닥이 못 뚫는 암반에 잘린 면적이 10 % 이상이면 3D Janbu 보정 값을 같이 봄 | 10 % | +| 흙 두께(토사 측점) | 암 경계 정보가 없는 측점의 흙 두께 | 3 m | +| 노면 하중 | E02 노면 하중(없으면 10 kPa)을 노면 띠 셀에 얹음 | E02 값 | +| 기준 안전율 | E02 기준 안전율 중 깎기 · 쌓기 가운데 큰 값(엄한 쪽) | 건기 1.5 · 우기 1.3(기본) | diff --git a/resources/help/E04/20_쓰인_방법과_근거.md b/resources/help/E04/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..c1706c8ec --- /dev/null +++ b/resources/help/E04/20_쓰인_방법과_근거.md @@ -0,0 +1,74 @@ +# 3D 해석 쓰인 방법과 근거 + +## 한눈에 + +| 단계 | 쓰인 방법 | 근거 | +| --- | --- | --- | +| 입력 지형 | 복사 DEM + 노선 계획면(B06 깎기 · 쌓기) 합성 | E01 복사본 · B06 횡단 설계 | +| 지층 | 지층 경계 격자(B06 암 경계를 측점 사이 보간) | B06 암 경계 · E02 물성 | +| 활동면 찾기 | Scoops3D 방식 구 격자 + 부피 제약 · 타원체 덧봄 | Reid 외(2015) USGS TM 14-A1 | +| 안전율(기본) | Hungr 3D Bishop 간편법 | Hungr(1987) · Hungr 외(1989) | +| 안전율(암반에 잘린 면) | 3D Janbu 간편법 + 보정 계수 | Hungr 외(1989) · Janbu(1973) | +| 안전율(비교) | Hovland 3D 단순 기둥법 | Hovland(1977) | + +- 지식DB 근거: `안정성평가/05_3D_사면안정해석_논문_기술정보` 1장(기둥법 수식) · 3장(Scoops3D) · 6.1절(구 격자 탐색) · 8장(3D/2D 비). +- 계산 코드는 수식만 옮겨 직접 짬(외부 해석 프로그램 안 씀). + +## 입력 지형 만들기 + +- 원지형 = 임도 프로젝트에서 복사해 온 DEM(지형 격자). +- 측점마다 B06 설계선(깎기 · 쌓기 단면)과 원지형의 높이 차를 구하고, 이웃 두 측점 사이는 횡단선을 서로 이은 면 위에서 그 차를 고르게 이어 붙임. +- 그래서 측점 위 단면은 B06 설계 단면과 같고, 노선 띠 밖은 원지형 그대로임. +- 비탈이 횡단 끝에서 원지형과 안 만나면 끝 기울기로 원지형과 만날 때까지 늘림(최대 60 m) — 띠 끝에 가짜 절벽이 생기지 않게. +- 해석 격자는 기둥 크기(기본 1 m) 정사각으로 다시 뜸. 구간 앞뒤 측점 하나씩을 더 붙여 구간 끝 지형이 끊기지 않게 함. + +## 지층 경계 + +- 위에서 아래로 성토 · 풍화토 · 리핑암(풍화암 물성) · 발파암(연암 물성) 네 층. +- 흙 두께 = 암 측점은 B06 암 경계 깊이 · 토사 측점은 3 m · 측점 사이는 고르게 보간, 노선 띠 밖은 가장 가까운 측점 값. +- 토사 측점 아래는 리핑암으로 봄. **발파암(연암)은 활동면이 못 뚫는 층** — 활동면이 그 윗면에서 잘림. +- 물성 값(γt · γsat · c · φ)은 E02 지반 조건 표의 성토 · 풍화토 · 풍화암 · 연암 줄을 그대로 씀. +- 노면 띠(두 노면 끝 사이)에 E02 노면 하중(기본 10 kPa)을 얹음. + +## 활동면 찾기 (Scoops3D 방식) + +- 노선 중심선 좌우 탐색 폭(기본 ±30 m) 안, 구간 안에 구 중심 자리를 격자로 깖. 중심 높이 = 그 자리 지표 + 2~30 m(2 m 간격). +- 중심마다 반지름을 1 m 씩 키우며 땅을 자르는 구를 만들고, 그 아래 흙 기둥들로 안전율을 셈. +- 부피 제약: 활동체 부피가 10 m³ 미만이면 빼고 10,000 m³ 를 넘으면 그 중심에서 더 키우지 않음. +- 거친 격자(중심 간격의 4 배)로 먼저 보고, 안전율이 낮게 나온 중심 둘레만 간격을 절반씩 좁혀 다시 봄. +- 셀마다 지나간 활동면 중 가장 낮은 안전율과 그 부피를 남김 → 셀별 최소 안전율 지도 · 임계 체적 지도. +- 구 + 타원체를 고르면, 위험하게 나온 중심에서 활동 방향에 직각으로 늘린 타원체(기본 2 배)를 더 봄. +- 한 구가 땅을 여러 덩이로 자르면 덩이마다 따로 셈. 격자 끝이나 지형 빈칸에 닿은 덩이는 버림. +- Scoops3D 와 다른 점: 중심 높이를 「그 자리 지표 위」 로 잡음(산지 고저차 대응) · 노선 띠 안에만 중심을 둠. + +## 안전율 셈 + +- 안전율 = 활동면을 따라 버티는 힘(점착력 + 마찰) ÷ 미끄러뜨리는 힘. 1 보다 작으면 무너지는 쪽. +- **Hungr 3D Bishop**: 기둥마다 위아래 힘이 맞도록 바닥 힘을 구하고, 회전축 둘레 힘의 모멘트가 맞도록 안전율을 되풀이 셈. 기본 방법. +- **3D Janbu 보정**: 회전 대신 활동 방향 수평 힘이 맞도록 셈한 뒤 보정 계수 f0 = 1 + b1 × (d/L − 1.4 × (d/L)²) 를 곱함(b1 = 점착 · 마찰 둘 다 0.50 · 마찰만 0.69 · 점착만 0.31 · d/L = 활동면 양끝을 이은 직선에서 바닥까지 가장 먼 거리 ÷ 활동 방향 길이). +- **Hovland**: 기둥 사이 힘을 모두 무시한 가장 단순한 셈. 판정에는 안 쓰고 비교로만 보여 줌. +- 다스리는 값: 바닥이 못 뚫는 암반에 잘린 면적이 10 % 이상이면 Bishop 과 Janbu 보정 중 **작은 값**, 그 밖은 Bishop. Bishop 이 수렴하지 않은 후보는 버림. 너무 큰 안전율은 100 으로 자름. +- 우기 = 지하수위면을 지표에 둔 간극수압(KDS 우기 규칙의 3D 꼴). 지진은 넣지 않음. +- 판정 = 다스리는 값 ≥ 기준 안전율(E02 깎기 · 쌓기 중 큰 값 · 기본 건기 1.5 · 우기 1.3, KDS 11 70 05). + +## 시험으로 맞춘 기준 문제 + +- Baligh & Azzouz 구 해석해 1.402 → 1.402. +- Zhang(1988) 경우 1 = 2.158(문헌 Hungr 2.167) · 경우 2 약층 = 1.617(1.620). +- Scoops3D 제방 30° = 1.325(1.32) · 원뿔 r_u 0.4 = 1.637(1.626). + +## 2D 와 다른 점 + +- 2D(E03)는 측점 횡단마다 폭이 끝없이 긴 사면으로 보고 셈 — 양옆 흙이 잡아 주는 힘(끝단 효과)이 없음. +- 3D 는 활동체 양옆 저항과 지형 굴곡(오목 · 볼록)을 넣음. 같은 땅이면 대개 3D 안전율이 2D 보다 크거나 같음(Cavounidis 1987). +- 문헌 경향(지식DB 05 8장): 폭 ÷ 높이 1~1.5 = 3D/2D 약 1.25~1.50 · 2~3 = 약 1.10~1.25 · 6 이상 = 약 1.01~1.05. +- 3D/2D 비가 1 보다 크면 2D 판정이 안전 쪽. 1 보다 작으면 측점 사이(20 m 간격 횡단이 놓친 곳)나 비스듬한 지형에 위험이 있을 수 있어 그 자리를 살펴볼 것. +- 3D 는 무너질 흙 양(m³)을 알려 줌 — 2D 는 못 줌. + +## 한계 + +- **3D 강도감소 FEM(유한요소) 안 함** — 활동면 모양을 미리 가정하지 않는 정밀 방법이지만 노선 전체에 쓰기엔 무거워 보류. +- 활동면 모양은 구 · 타원체(암반에 잘린 면 포함)만. 비대칭 엄밀해(Cheng & Yip · 3D Spencer 등)는 안 씀. +- 지층은 네 층 고정. E02 에서 더한 지층 · 구간별 덮어쓰기 · 지반 라벨 바꿈 · 붕적토 · 경암은 3D 에 안 들어감. 흙 두께는 B06 암 경계(없으면 3 m)를 씀. +- 우기는 지표까지 포화 한 가지. 지진 · 옹벽 · 석축 · 배수 시설은 넣지 않음. +- 물성은 문헌 추정치 · 현장 시험 없음. 시추 · 시험 성과가 있으면 E02 에서 바꿀 것. diff --git a/resources/help/E04/30_사용자_확정_전_값.md b/resources/help/E04/30_사용자_확정_전_값.md new file mode 100644 index 000000000..837924bb6 --- /dev/null +++ b/resources/help/E04/30_사용자_확정_전_값.md @@ -0,0 +1,30 @@ +# 3D 해석 사용자 확정 전 값 + +- 아래 값은 근거 문헌 · 기술정보를 보고 정한 지금 기본값임. 사용자 협의로 확정되기 전이며 바뀔 수 있음. +- 「화면」 = 탐색 설정 · 고급 설정 칸에서 바꿀 수 있음 · 「고정」 = 화면에서 못 바꿈. + +| 값 | 지금 기본값 | 출처 · 까닭 | 바꾸는 곳 | 상태 | +| --- | --- | --- | --- | --- | +| 3D 기본 방법 | Hungr 3D Bishop · 암반에 잘린 면은 3D Janbu 보정과 작은 값 · Hovland 비교 | 지식DB 05 1장 · 요구서 345 4-2 | 고정 | 사용자 확정 전 | +| Janbu 보정 전환 문턱 | 잘린 바닥 면적 10 % | 기둥 몇 개만 닿아 지도에 이음매가 생기는 것을 막는 값 · Zhang(1988) 약층 경우 잘림 비 33 % | 고정 | 사용자 확정 전 | +| 활동면 모양 | 구 + 타원체 | Scoops3D · Hungr(1989) 타원체 꼴 | 화면 | 사용자 확정 전 | +| 타원체 길이비 | 2.0 | 브레인 정함 | 화면 | 사용자 확정 전 | +| 탐색 폭 | 중심선 ±30 m | 브레인 정함 · 지식DB 05 6.1절(노선 따라 탐색 상자 · 문헌 예 ±50 m) | 화면 | 사용자 확정 전 | +| 기둥 크기 | 1.0 m | 브레인 정함 | 화면 | 사용자 확정 전 | +| 구 중심 간격 · 높이 범위 · 간격 | 2 m · 지표 위 2~30 m · 2 m | 브레인 정함 · Scoops3D 탐색 격자 방식 | 화면 | 사용자 확정 전 | +| 반지름 증분 · 최대 깊이 | 1 m · 15 m | 브레인 정함 · Scoops3D 방식 | 화면 | 사용자 확정 전 | +| 부피 범위 | 10~10,000 m³ | 브레인 정함 · 지식DB 05 6.1절(문헌 예 50~50,000 m³) | 화면 | 사용자 확정 전 | +| 토사 측점 흙 두께 | 3 m | 국내 산지 풍화토 · 붕적토 1~3 m 의 큰 쪽 | 고정 | 사용자 확정 전 | +| 못 뚫는 층 | 발파암(연암 물성) | 토사 측점 아래 = 리핑암(풍화암 물성)으로 봄 | 고정 | 사용자 확정 전 | +| 우기 지하수 | 지표까지 포화 | KDS 우기 규칙(토사)의 3D 꼴 | 고정 | 사용자 확정 전 | +| 3D 기준 안전율 | E02 깎기 · 쌓기 중 큰 값(건기 1.5 · 우기 1.3) | KDS 11 70 05 · 한 구간에 깎기 · 쌓기가 섞여 엄한 쪽 | E02 기준 안전율 | 사용자 확정 전 | +| 자동 구간 길이 | 100 m(경계 = 측점) · 꼬리 50 m 미만은 앞에 붙임 | 브레인 정함 | 고정(시작 · 끝 칸으로 직접 지정 가능) | 사용자 확정 전 | +| 한 번에 해석할 구간 상한 | 500 m | 브레인 정함(계산 시간 · 메모리) | 고정 | 사용자 확정 전 | +| 안전율 색 단계 | 1.0 · 1.2 · 1.3 · 1.5 · 2.0 · 3.0 | 1.3(우기) · 1.5(건기) 기준 경계 | 고정 | 사용자 확정 전 | + +## 다른 화면에서 오는 값 + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 지반 물성치(성토 · 풍화토 · 풍화암 · 연암) | E02 표 값 | 문헌 추정치 · 현장 시험 없음(지식DB 04 · 07) | 사용자가 E02 에서 바꿈 | +| 노면 하중 | 10 kPa | 옹벽 배면 상재하중 준용 | 사용자가 E02 에서 바꿈 | diff --git a/resources/help/E05/00_설명서.md b/resources/help/E05/00_설명서.md new file mode 100644 index 000000000..5342c10a1 --- /dev/null +++ b/resources/help/E05/00_설명서.md @@ -0,0 +1,55 @@ +# 보고서 설명서 + +## 이 화면에서 하는 일 + +- E02~E04 · E07 · E03 보강 검토 결과를 모아 비탈면 안정성 검토 보고서를 엑셀 꼴로 만듦. +- 가운데 = 내장 엑셀(시트 탭 · 그림 · 쪽 나눔 점선)로 보고서를 바로 보고 고침. +- 왼쪽 = 안내 글 · 상태 줄 · 내보내기 단추 · 저장 단추. +- 내보내기 = 엑셀(xlsx) · PDF 파일. +- 처음 들어오면 서버가 보고서를 한 번 만듦(2D 해석 결과가 있을 때만). 그 첫 판이 [초기화] 의 되돌림 기준이 됨. + +## 작업 순서 + +1. E03 측점 해석에서 **[해석]** 을 먼저 끝냄(없으면 가운데에 「2D 해석 결과가 없습니다 — E03 에서 [해석] 뒤 만드세요.」). +2. 필요하면 E04 3D 해석 · E07 광역 해석 · E03 보강 검토도 끝냄(하지 않은 부분은 시트에 「… 전」 으로 남음). +3. E05 를 열면 보고서가 뜸. 아래 시트 탭(표지 · 개요 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토)을 눌러 차례로 확인함. +4. **표지** 의 발주처 · 작성자 칸, **개요** 의 「4. 의견」 칸에 글을 씀. +5. 왼쪽 **[저장]** 을 누름(「저장했습니다.」). +6. 해석을 다시 했으면 가운데 위 주의 색 띠 「해석 결과가 바뀜 —」 옆 **[보고서 갱신]** 을 누름. +7. **[PDF 미리보기]** 로 인쇄 모양을 보고, **[엑셀]** 또는 **[PDF]** 로 내려받음. + +## 단추 + +| 자리 | 단추 | 하는 일 | +| --- | --- | --- | +| 왼쪽 위 줄 | **[엑셀]** | 지금 화면의 보고서를 `안정성평가_보고서.xlsx` 로 내려받음 · 저장은 안 함 | +| 왼쪽 위 줄 | **[PDF]** | 지금 화면의 보고서를 `안정성평가_보고서.pdf` 로 내려받음 · 저장은 안 함 | +| 왼쪽 위 줄 | **[PDF 미리보기]** | PDF 를 화면 안 창으로 띄움 · **[닫기]** 로 닫음 | +| 왼쪽 아래 줄 | **[보고서 갱신]** | 프로그램 몫 시트 · 그림만 지금 해석 결과로 다시 씀 · 표지 · 개요 · 사용자가 더한 시트는 그대로 | +| 왼쪽 아래 줄 | **[초기화]** | 처음 만든 보고서로 되돌림 · 확인 창 「처음 만든 보고서로 되돌립니다. 고친 내용 · 갱신이 모두 사라집니다.」 | +| 왼쪽 아래 줄 | **[저장]** | 고친 보고서를 서버에 저장 · 고친 것이 있을 때만 켜짐 · 수식은 서버가 다시 풂 | +| 가운데 위 띠 | **[보고서 갱신]** | 왼쪽 같은 이름 단추와 같음 · 해석 결과가 바뀐 때만 띠가 뜸 | + +- 저장 안 한 고침이 있는데 [보고서 갱신] 을 누르면 「저장 안 한 고침이 있습니다. 저장한 뒤 갱신할까요?」 → **[저장]** 을 고르면 저장 뒤 갱신함. +- 시트 탭 줄 끝 **+** = 빈 시트 추가 · 탭 오른쪽 누름 메뉴 = 이름 바꾸기 · 복사 · 숨기기 · 삭제. + +## 결과 읽는 법 + +- 상태 줄(왼쪽): 「저장 안 된 변경이 있습니다.」 · 「만드는 중…」 · 「보고서 만든 때 날짜 시각」. +- 판정 칸 `O.K` = 초록 채움 · `N.G` = 빨강 채움. +- 개요의 측점 수 · 최소 안전율 · 종합 판정 · 판정 글은 측점표 · 입력조건 시트를 가리키는 수식이라 갱신하면 같이 바뀜. +- 시트마다 새 쪽에서 시작. 긴 표(측점표 등)는 자동으로 쪽이 나뉘고 머리 줄이 쪽마다 되풀이됨. 쪽 나눔은 화면에 점선으로 보임. +- 시트별 내용은 「시트 구성」 문서 참고. + +## 알아 둘 것 + +- **문헌 추정치 · 현장 시험 없음** — 표지 · 입력조건 시트에 고정 글로 들어 있음. 지반 물성치는 문헌 · 기준값을 적용한 추정치이며 현장 조사 · 시험 성과를 반영하지 않았음. 입력조건 물성 표 「출처」 열 = 기본값 그대로면 「문헌 추정치(기본값)」 · 바꿨으면 「사용자 입력」. +- **사용자 몫** = 표지 · 개요 — 처음 만들 때만 채우고 [보고서 갱신] 때 건드리지 않음. **프로그램 몫** = 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토 — 갱신 때 통째로 다시 씀. +- 프로그램 몫 시트를 손으로 고쳤다면 갱신 때 덮임. 이때 「고친 내용이 덮인 시트 — 옛 판은 _지난/ 에 보관: 시트 이름」 알림이 뜨고 옛 판은 서버에 보관됨. 오래 남길 글은 개요 의견 칸이나 새 시트에 쓸 것. +- 프로그램 몫 시트를 지웠어도 갱신하면 끝에 다시 생김. 보강검토 시트는 E03 보강 검토 결과가 있을 때만 생김. +- 고친 내용은 [저장] 전까지 이 브라우저 탭에만 남음(자동 저장 없음). 탭을 닫기 전 [저장] 할 것. +- [엑셀] · [PDF] 는 화면에 지금 보이는 판(저장 안 한 고침 포함)으로 만듦. 보기만 허용된 프로젝트는 서버에 저장된 판으로 만듦. +- 다른 곳에서 먼저 저장했으면 「다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요.」 → 화면을 다시 열 것. +- 보기만 허용된 프로젝트에서는 「보기만 허용된 프로젝트입니다.」 가 뜨고 [저장] · [초기화] · [보고서 갱신] 이 꺼짐(내보내기는 됨). +- 왼쪽 안내 글은 입력조건 · 측점표 · 대표단면 · 3D요약만 적고 있으나, 광역요약 · 보강검토도 갱신 때 다시 씀. +- **「사용자 확정 전」 표기** — E07 광역 화면에는 뿌리 점착 1 kPa · 측점 등급 문턱 5 % · 20 % 가 「사용자 확정 전」 으로 적혀 있으나, 보고서 광역요약 시트에는 그 줄이 아직 없음(준비 중). 값 자체는 광역요약 「토층 물성」 줄 · 「위험 측점」 제목에 들어감. diff --git a/resources/help/E05/10_시트_구성.md b/resources/help/E05/10_시트_구성.md new file mode 100644 index 000000000..e49665421 --- /dev/null +++ b/resources/help/E05/10_시트_구성.md @@ -0,0 +1,66 @@ +# 보고서 시트 구성 + +| 차례 | 시트 | 몫 | 용지 | 내용 출처 | +| --- | --- | --- | --- | --- | +| 1 | 표지 | 사용자 | A4 세로 | 처음 만들 때 사업명 · 노선명 · 작성일만 채움 | +| 2 | 개요 | 사용자 | A4 세로 | 수식이 측점표 · 입력조건을 따라감 | +| 3 | 입력조건 | 프로그램 | A4 세로 | E02 지반 조건 · 해석 방법 | +| 4 | 측점표 | 프로그램 | A4 가로 | E03 측점 해석 | +| 5 | 대표단면 | 프로그램 | A4 세로 | E03 대표 단면 · 그림 | +| 6 | 3D요약 | 프로그램 | A4 세로 | E04 3D 해석 · 평면 위험 띠 그림 | +| 7 | 광역요약 | 프로그램 | A4 세로 | E07 광역 해석 | +| 8 | 보강검토 | 프로그램 | A4 세로 | E03 보강 검토(있을 때만) | + +## 1. 표지 + +- 제목 「비탈면 안정성 검토 보고서」 · 부제 「임도 노선 비탈면 안정해석 결과」. +- 사업명 · 노선명(지역 · 임도 종류) · 작성일 = 자동 · 발주처 · 작성자 = 사용자가 씀. +- 「문헌 추정치 · 현장 시험 없음」 과 주의 글 「※ 지반 물성치는 문헌 · 기준값을 적용한 추정치이며 현장 조사 · 시험 성과를 반영하지 않았음.」 고정. + +## 2. 개요 + +- 1. 해석 대상 — 대상 측점 수(개소) · 해석 구간 연장(m). +- 2. 조건별 최소 안전율 — 건기 · 우기 · 지진마다 최소 안전율 · 기준 안전율 · 판정 · 최소 안전율 측점. 지진을 안 쓰면 「-」. +- 3. 종합 판정 — 기준 미달(N.G) 측점 수 · 종합 판정 · 판정 글(예: 「대상 측점 57개소 중 3개소가 기준 안전율에 미달함. 미달 구간은 보강 대책 검토가 필요함.」). +- 4. 의견 — 사용자 글 칸. + +## 3. 입력조건 + +- 1. 해석 방법 — 해석 기법(Bishop 간편법 기본 · Janbu 보정 · Fellenius 비교) · 활동면 탐색(원호 + 비원호 · 좌우 비탈 따로) · 3D 해석(Hungr 3D Bishop(+Janbu 보정) · 구 · 타원체 탐색, 3D 를 안 했으면 「3D 해석 전」) · 적용 기준(KDS 11 70 05). +- 2. 기준 안전율 — 건기 · 우기 · 지진 값 = 깎기 · 쌓기 중 엄한 쪽 · 비고에 둘 다 적음. +- 3. 지하수 규칙 — 건기 · 우기(토사 · 풍화암 / 암반) · 지진 시. +- 4. 노면 하중(kPa). +- 5. 지반 물성치 — 지층 · 적용 구간(전체 · 구간 글) · γt · γsat · c · φ · 출처(「문헌 추정치(기본값)」 또는 「사용자 입력」). E02 구간별 덮어쓰기도 줄로 더해짐. + +## 4. 측점표 + +- 측점 하나 = 한 줄 · 측점 · 누가거리 · 구분(깎기 · 쌓기 중 기준 대비 가장 낮은 쪽) · 건기 · 우기 · 지진 안전율 · 기준 안전율 · 판정. +- 판정 = 모든 조건이 기준 이상이면 O.K · 하나라도 미달이면 N.G. + +## 5. 대표단면 + +- 단면 하나 = 한 쪽 · 제목(대표 단면 ① — 측점 · 깎기/쌓기 · 높이) · 선정 사유 · 단면 그림(지반선 · 설계면 · 암선 · 노면 · 비탈 높이 · 조건별 임계 활동면). +- 선정 사유 = 「쌓기 높이 10 m 이상」 · 「깎기 높이 15 m 이상」 · 「최소 안전율 하위 5」 · 「사용자 지정」(E03 대표 단면 켬). +- 조건별 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · y · 비고(원호 · 비원호 · 지반 · 토층 두께). +- 대표 단면이 없으면 「대표 단면 — 해당 없음」. + +## 6. 3D요약 + +- 1. 평면 위험 띠 그림 — 노선 · 측점 2D 판정 색 · 3D 구간 띠(O.K · N.G). 3D 를 안 했으면 「3D 해석 전」. +- 2. 구간별 3D 안전율 — 구간 · 시작 측점 · 끝 측점 · 3D 최소 Fs · 2D 최소 Fs · 3D/2D 비(수식) · 임계 체적(m³) · 판정. +- 2D 최소 Fs = 그 구간 측점들의 **같은 조건**(3D 를 셈한 건기 또는 우기) 최소값. 판정 · 기준은 E04 값을 그대로 씀. +- 3D/2D 비가 1 보다 크면 2D 가 안전 쪽. + +## 7. 광역요약 + +- 1. 해석 조건 — 노선 띠(중심선 ± m · 격자) · 토층 물성(γt · γsat · c · φ · 뿌리 점착 · 토층 두께) · 시나리오(표준 SINMAP 습윤지수 또는 최악 완전 포화) · 해석 때. +- 2. 등급별 면적 — 안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정의 SI 범위 · 면적(m²) · 비율(%) · 합계, 옆에 측점 등급(낮음 · 보통 · 높음)별 측점 수. +- 3. 위험 측점 — 보통 · 높음 측점만 한 줄씩 · 최소 Fs · 최소 SI · 등급(SI<1 비율) · 위험 면적 · 최대 경사. 없으면 「위험 측점 없음」. +- E07 해석 전이면 「광역 해석 전 — E07 광역에서 [해석] 뒤 [보고서 갱신]」. + +## 8. 보강검토(가정) + +- 머리 글 「E 안 가정 검토 — 비탈 완화 · 소단은 형상만 바꿔 다시 해석 · 설계(임도)에 안 돌려 씀 · 문헌 추정치」. +- 1. 비탈 완화 · 소단 가정 — 측점 · 비탈 · 가정(1:n 전 → 후 · 소단 · 최소 완화) · 조건 · 전 Fs · 후 Fs · 기준 · 전 판정 · 후 판정. +- 2. 벽 외적 안정 — 측점 · 벽 · 평상시/지진 시 · 토압법 · 배면 수위 · 활동 · 전도 · 지지력 Fs와 기준(KDS 11 80 05) · 판정. +- E03 보강 검토 결과가 없으면 시트 자체가 빠짐. diff --git a/resources/help/E06/00_설명서.md b/resources/help/E06/00_설명서.md new file mode 100644 index 000000000..89e38d24a --- /dev/null +++ b/resources/help/E06/00_설명서.md @@ -0,0 +1,72 @@ +# 암반 해석 설명서 + +## 이 화면에서 하는 일 + +암 측점(리핑암 · 발파암)의 깎기 비탈이 절리(암반 틈) 때문에 무너질 수 있는지 봄. +현장에서 조사한 절리 방향을 넣으면 측점마다 두 가지를 셈. + +- **Markland 판정** — 절리 방향만 보고 평면 · 쐐기 · 전도 가운데 어떤 무너짐이 생길 수 있는지 가림. +- **Hoek-Bray 안전율** — 평면 · 쐐기로 판정된 것만 건기 · 우기(· 지진) 안전율을 셈. 전도는 판정만 함. + +사면 방향 · 경사 · 높이는 복사본 횡단에서, 물성 · 지진 · 기준 안전율은 지반 조건(E02)에서 가져옴. + +## 작업 순서 + +1. 좌측 **절리군** 카드에서 [+ 절리군] 을 눌러 줄을 늘림. +2. 줄마다 이름 · 구간(시작 · 끝, m) · 경사방향 · 경사 · φj · cj · 지속성을 넣음. 칸 뜻은 `10_칸_뜻` 참고. +3. 필요하면 **해석 설정** 카드(측방 한계 · 암 단위중량 · 우기 수위 비 · 인장균열 · 볼트)를 고침. +4. [저장] 을 누름. 빨간 칸이 있으면 저장되지 않음. +5. [해석] 을 누름. 저장 안 한 고침이 있으면 먼저 저장한 뒤 셈. +6. 가운데 **위험 띠** 나 우측 **측점 결과** 표에서 측점을 눌러 스테레오넷 · 안전율 표를 봄. + +## 단추 + +| 단추 | 자리 | 하는 일 | +| --- | --- | --- | +| [+ 절리군] | 좌 · 절리군 카드 | 새 줄 추가(구간 = 0 m ~ 마지막 암 측점 · 경사방향 0° · 경사 45° · φj 30° · cj 0 kPa) | +| [×] | 좌 · 절리군 줄 끝 | 그 줄 지움 | +| [초기화] | 좌 아래 | 확인 창 뒤 절리군 · 설정을 처음 상태(절리 없음 · 기본 설정)로 되돌림 | +| [저장] | 좌 아래 | 절리군 · 설정을 작업본에 씀 | +| [해석] | 좌 아래 | 암 측점 × 깎기 쪽을 모두 셈 · 입력이 지난번과 같으면 지난 결과를 씀 | +| [등각] [등면적] | 가운데 위 | 스테레오넷 투영 바꿈(보기만 · 저장 안 됨) | +| [전체] [평면] [쐐기] [전도] | 가운데 위 | 스테레오넷에 그 모드 위험 영역만 보임 | + +- 칸을 고치기만 하면 이 탭 안에만 남음(자동 저장 없음). 「저장 안 된 변경이 있습니다.」가 뜨면 [저장] 할 것. +- 보기만 허용된 프로젝트는 칸 · 단추가 잠김. + +## 결과 읽는 법 + +**가운데 위 · 위험 띠** — 누가거리 축에 측점마다 가장 나쁜 판정 색. 띠 조각을 누르면 그 측점이 열림. 왼쪽에 판정별 측점 수. + +**가운데 · 스테레오넷** — 하반구 투영. 그림 보는 법은 `20_쓰인_방법과_근거` 의 스테레오넷 절 참고. + +- 굵은 선 · 네모 = 사면 대원 · 사면 극점 +- 점 + 이름 = 절리 극점(평면 · 전도 보기) · 세모 + 이름 = 두 절리 교선(쐐기 보기) +- 테두리가 강조된 점 · 세모 = 이 측점에서 위험으로 판정된 것 +- 원 = 마찰원 φ · 색 영역 = 평면 · 쐐기 · 전도 위험 영역 · 선 = 일광 엔벨로프(평면) + +**우측 · 측점 결과 표** — 측점 · 쪽(좌 · 우) · 평 · 쐐 · 전(위험 모드 수) · 최소 Fs · 판정. + +| 판정 | 뜻 | +| --- | --- | +| N.G | 안전율이 기준보다 낮은 경우가 하나라도 있음 | +| 검토 | 전도 위험이 있음, 또는 위험 판정은 났으나 블록이 안 생겨 안전율을 못 셈 | +| O.K | 위험 모드는 있으나 모든 안전율이 기준 이상 | +| 위험 없음 | Markland 판정에서 위험 모드 없음 | + +**우측 아래 · 고른 측점** — 사면 경사방향 / 경사 · 높이 · 윗면 경사 · 암 단위중량 한 줄, 모드별 위험 절리, 안전율 표(모드 · 절리 · 조건 · Fs · 기준 · 판정). + +- Fs 옆 「(블록 안 생김)」 = 그 조건에서 덩이가 만들어지지 않음. +- Fs 옆 「(지속성 < 활동면)」 = 절리 길이가 활동면보다 짧음 · 실제로는 덩이가 덜 생길 수 있음. +- 기준 = 지반 조건(E02)의 깎기 기준 안전율(기본 건기 1.5 · 우기 1.2 · 지진 1.1). + +## 알아 둘 것 + +- **문헌 추정치 · 현장 시험 없음.** 절리면 φj · cj · 암 단위중량 기본값은 문헌 값임. 현장 조사 · 시험 값으로 바꿀 것. +- 절리 방향은 반드시 현장 조사값을 넣을 것. 절리가 없으면 해석할 것이 없음. +- 주향으로 적은 야장은 경사방향 = 주향 + 90°(오른손 법칙)로 바꿔 넣을 것. 주향 입력 칸은 없음. +- 대상 = 암 측점의 깎기 쪽 중 높이 1 m 이상만. 토사 측점 · 쌓기 쪽은 2D 해석(E03)에서 봄. +- 지진 조건은 지반 조건(E02)에서 지진을 켠 때만 셈. +- 절리 · 지반 조건이 해석 뒤 바뀌면 가운데 위에 「[해석] 을 다시 누르세요」 띠가 뜸. +- 전도는 기하 판정만 함. 블록 전도 안전율은 별도 정밀 검토 몫. +- 보고서(E05)에 암반 결과 시트는 **준비 중**. diff --git a/resources/help/E06/10_칸_뜻.md b/resources/help/E06/10_칸_뜻.md new file mode 100644 index 000000000..24ec84671 --- /dev/null +++ b/resources/help/E06/10_칸_뜻.md @@ -0,0 +1,51 @@ +# 칸 뜻 + +범위 밖 · 빈 칸(필수)은 칸이 빨갛게 되고 마우스를 올리면 「범위 밖 (아래 ~ 위)」가 보임. 빨간 칸이 있으면 [저장] · [해석] 이 안 됨. + +## 절리군 표 (좌 · 절리군 카드) + +한 줄 = 방향이 비슷한 절리 무리 하나. 현장 조사값을 넣음. + +| 칸 | 뜻 | 단위 | 범위 | 새 줄 기본 | +| --- | --- | --- | --- | --- | +| 이름 | 절리군 이름(그림 · 표에 쓰임) | - | 비우면 안 됨 | J1, J2 … | +| 시작 · 끝 | 이 절리군을 쓰는 누가거리 구간 · 구간 안 측점에만 씀 | m | 0 ~ 100,000 · 끝 ≥ 시작 | 0 ~ 마지막 암 측점 | +| 경사방향 | 절리면이 기울어 내려가는 방위(북 0° · 시계 방향) | ° | 0 ~ 360 | 0 | +| 경사 | 절리면이 수평에서 기운 각 | ° | 0 ~ 90 | 45 | +| φj | 절리면 마찰각 | ° | 0 ~ 60 | 30 | +| cj | 절리면 점착력 | kPa | 0 ~ 1,000 | 0 | +| 지속성 | 절리가 이어지는 길이 · 비우면 끝없이 이어진다고 봄 | m | 0 ~ 1,000 | 빈 칸 | + +- 한 측점에 구간이 겹치는 절리군이 여럿이면 모두 함께 봄(쐐기 = 두 절리군 짝마다). +- 새 줄 φj 30° · cj 0 kPa = 지식DB 07 절리면 범위(φj 25~35° · cj 0~30 kPa)의 보수 쪽. +- 지속성은 안전율을 바꾸지 않음. 활동면보다 짧으면 결과 표에 「(지속성 < 활동면)」 표시만 붙음. + +## 해석 설정 (좌 · 해석 설정 카드) + +| 칸 | 뜻 | 범위 | 기본 | +| --- | --- | --- | --- | +| 평면 측방 한계 (°) | 사면과 절리 경사방향 차이가 이 각 안이면 평면 무너짐 후보 | 5 ~ 45 | 20 | +| 전도 측방 한계 (°) | 절리가 사면 반대로 기운 방향 차이가 이 각 안이면 전도 후보 | 5 ~ 45 | 20 | +| 암 단위중량 (kN/m³ · 비우면 E02) | 덩이 무게 계산 값 · 비우면 지반 조건(E02)의 그 측점 암 물성 γt · 그것도 없으면 25 | 15 ~ 35 | 빈 칸 | +| 우기 수위 비 (0~1) | 우기 조건 물 차는 정도 · 0 = 마름 · 1 = 가득 참 | 0 ~ 1 | 0.5 | +| 인장균열 | 사면 윗면에서 아래로 갈라진 틈을 볼지 · 없음 · 임계(최소 안전율 자리) · 깊이 지정 | - | 없음 | +| 균열 깊이 (m) | 「깊이 지정」 때만 보임 · 윗면에서 잰 균열 깊이 | 0.1 ~ 100 | 1 | +| 볼트 힘 (평면 kN/m · 쐐기 kN) | 록볼트 · 앵커가 잡아 주는 힘 · 평면은 폭 1 m 당 · 쐐기는 덩이 하나당 | 0 ~ 5,000 | 0 | +| 볼트 경사 (수평 아래 °) | 볼트가 산 쪽으로 박히는 각(수평 아래가 +) | -30 ~ 60 | 15 | + +- 측방 한계 기본 20° = 상용 Dips 기본 · 지식DB 03 은 전도에 30° 를 씀 — 화면에서 바꿀 수 있음. +- 우기 수위 비 기본 0.5 = 비탈 높이 ½ 수위(KDS 11 70 05 암반). +- 인장균열 「임계」 = 균열 자리를 마루 뒤로 옮겨 가며 안전율이 가장 낮은 자리를 찾음. 균열 없는 쪽이 더 낮으면 균열 없음으로 셈. 평면 무너짐에만 씀. +- 볼트 = 수동 지지력(덩이가 움직이면 버팀). 0 이면 보강 없음. + +## 결과 칸 (우측) + +| 칸 | 뜻 | +| --- | --- | +| 쪽 | 노선 좌 · 우 깎기 비탈 | +| 평 · 쐐 · 전 | 위험으로 판정된 평면 절리 수 · 쐐기 짝 수 · 전도 절리 수 | +| 최소 Fs | 그 비탈 모든 경우 중 가장 낮은 안전율 | +| 사면 줄 | 사면 경사방향 / 경사 · 높이 · 윗면(비탈 끝 바깥 원지반) 경사 · 쓴 암 단위중량 | +| 쐐기 (추향/선주각) | 두 절리 교선이 향하는 방위 / 수평 아래 기운 각 | +| 조건 | 건기(물 없음) · 우기(우기 수위 비) · 지진(E02 kh) | +| 기준 | 지반 조건(E02) 깎기 기준 안전율 | diff --git a/resources/help/E06/20_쓰인_방법과_근거.md b/resources/help/E06/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..d9763ab61 --- /dev/null +++ b/resources/help/E06/20_쓰인_방법과_근거.md @@ -0,0 +1,72 @@ +# 쓰인 방법과 근거 + +## 근거 문서 + +- 지식DB `안정성평가/03` 암반 비탈면 평사투영 및 블록 해석 수식 명세 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율. +- Hoek & Bray 「Rock Slope Engineering」 · Rocscience Dips · RocPlane · SWedge 이론서(식만 따름 · 프로그램은 쓰지 않음). +- KDS 11 70 05 — 우기 수위(암반 = 비탈 높이 ½). +- 지식DB `안정성평가/07` — 암 단위중량(25~27 kN/m³) · 절리면 강도 범위. + +## 방향 적는 법 + +- 면 = 경사방향 / 경사. 예: 135/60 = 남동쪽으로 60° 기운 면. +- 선 = 추향 / 선주각. 추향 = 선이 내려가는 방위 · 선주각 = 수평 아래로 기운 각. +- 극점 = 면에 수직인 선. 면이 완만할수록 극점이 그림 가운데로 감. +- 사면 경사방향 = 그 측점 노선 방향에 직각으로, 비탈이 노면 쪽으로 내려가는 방위(프로그램이 자동으로 셈). + +## Markland 판정 (무너질 수 있는 모양인지) + +절리 방향 · 사면 방향 · 마찰각만 봄. 힘 계산 없음. + +| 모드 | 조건(모두 맞으면 위험) | +| --- | --- | +| 평면 | 절리 경사방향이 사면 경사방향 ± 평면 측방 한계 안 · φj < 절리 경사 < 그 방향에서 본 사면 경사 | +| 쐐기 | 두 절리 교선이 사면 쪽(방위 차 90° 안)으로 향함 · φ < 교선 선주각 < 그 방향에서 본 사면 경사 · φ = 두 절리 중 작은 φj | +| 전도 | 절리가 사면 반대쪽으로 기움(경사방향 차가 180° ± 전도 측방 한계 안) · 절리 경사 > 90° − 사면 경사 + φj | + +쉬운 말 풀이 +- 평면 = 사면과 같은 쪽으로 기운 절리 하나를 따라 덩이가 미끄러짐. 절리가 사면보다 완만해야 비탈 면에 드러남(일광). 마찰각보다 가팔라야 미끄러짐. +- 쐐기 = 두 절리가 만나 생긴 V 자 홈을 따라 덩이가 미끄러짐. 판정은 교선 하나로 봄. +- 전도 = 사면 반대로 가파르게 선 절리 사이 기둥이 앞으로 넘어짐. +- 「그 방향에서 본 사면 경사」(겉보기 경사) = 사면을 비스듬히 볼 때 덜 가팔라 보이는 경사. 지식DB 03 의 평면 조건은 사면 경사 그대로를 쓰나, 이 화면은 겉보기 경사를 씀(측방 한계 안에서 차이가 작음). + +## Hoek-Bray 평면 안전율 + +안전율 = 버티는 힘 ÷ 미는 힘. +- 버티는 힘 = cj × 활동면 길이 + (면을 누르는 힘) × tanφj + 볼트가 버티는 몫 +- 미는 힘 = 덩이 무게가 면을 따라 미는 몫 + 수압 · 지진 힘이 미는 몫 +- 덩이 = 단위 폭(1 m) 2D 단면 · 윗면은 비탈 끝 바깥 원지반 경사로 오름. +- 물 · 인장균열 없음 = 활동면 가운데 수두가 (활동면 오름 높이 × 수위 비 ÷ 2)인 삼각형 양압력. +- 물 · 인장균열 있음 = 균열 안 수심 = 균열 깊이 × 수위 비 · 균열 수평 수압 + 활동면 양압력. +- 지진 = 바깥 쪽 수평 힘(무게 × kh). +- 활동면이 윗면과 안 만나 덩이가 안 생기면 「(블록 안 생김)」. + +## Hoek-Bray 쐐기 안전율 + +- 덩이 = 두 절리 · 사면 · 윗면 네 면이 만드는 삼각뿔. 부피 × 암 단위중량 = 무게. +- 무게 · 수압 · 지진 · 볼트 힘을 3차원 벡터로 더한 뒤 미끄러지는 길을 고름 — 두 면을 함께 타고 미끄러짐 · 한 면만 타고 미끄러짐 · 들려 떨어짐. +- 각 면 수직력으로 마찰 저항을 구해 안전율 = 버티는 힘 ÷ 미는 힘. 들린 면은 저항 없음. +- 물 = 수위 비가 1 이면 지식DB 03 평균 수두(γw × Hw ÷ 6) · 수위 비 p 이면 그 p³ 배. Hw = 교선이 윗면에 닿는 높이. +- 지진 = 교선 방향 수평 힘. 볼트 = 산 쪽(사면 반대 방위)으로 박힌 수동력. +- 지식DB 03 의 닫힌 꼴 쐐기 식 대신 같은 이론의 벡터 풀이(SWedge 이론서 꼴)를 씀 — 한 면 미끄러짐 · 들림까지 가림. + +## 스테레오넷 읽는 법 + +- 구를 아래 반쪽만 보고(하반구) 평면에 펼친 그림. 위 = 북 · 가장자리 = 수평 · 가운데 = 연직. +- 등각 = 각도 모양이 바르게 보임 · 등면적 = 넓이 비율이 바르게 보임. 판정 결과는 같음. +- **마찰원** = 마찰각 φ 원. 그림의 φ = 그 측점 절리 중 가장 작은 φj(제목 줄에 표시) · 표 판정은 절리마다 제 φj 를 씀 — φj 가 서로 다르면 그림과 표가 조금 다를 수 있음. + - 극점 보기(평면 · 전도) — 극점이 마찰원 안이면 절리가 φ 보다 완만해 미끄러지지 않음. + - 교선 보기(쐐기) — 교선이 마찰원 안이면 φ 보다 가팔라 미끄러질 수 있음. +- **평면 위험 영역** = 마찰원 밖 · 일광 엔벨로프 안 · 사면 반대편 측방 한계 부채꼴. 극점이 이 안이면 평면 위험. +- **쐐기 위험 영역** = 사면 대원 밖 · 교선 마찰원 안 · 사면 쪽 반원. 교선 세모가 이 안이면 쐐기 위험. +- **전도 위험 영역** = 가장자리 쪽 · 사면 경사방향 측방 한계 부채꼴. 극점이 이 안이면 전도 위험. +- 영역 경계는 Markland 조건과 같은 식으로 그림. + +## 한계 + +- 문헌 추정치 · 현장 시험 없음. 결과는 넣은 절리 값만큼만 맞음. +- 2D 평면 · 단순 삼각뿔 쐐기만 봄. 여러 덩이가 이어진 무너짐 · 단계 깎기 소단 · 지형 굴곡은 안 봄. +- 사면 높이 1 m 미만 깎기는 뺌. 윗면 = 비탈 끝 바깥 max(높이, 5 m) 원지반 평균 오르막(사면 경사 − 1° 까지). +- 지속성은 표시만 하고 안전율 · 판정에 안 넣음. +- 전도는 기하 판정만 함(Goodman-Bray 블록 전도 안전율 없음). +- 평면 우기 물: 지식DB 03 의 「균열 없음 · ½ 수위」 식(U = ¼ γw H A)은 이 화면 수위 비 1 과 같은 꼴임. 기본 0.5 는 그 반 수두 — 설계 목적에 맞게 수위 비를 정할 것. diff --git a/resources/help/E07/00_설명서.md b/resources/help/E07/00_설명서.md new file mode 100644 index 000000000..ed45bdf69 --- /dev/null +++ b/resources/help/E07/00_설명서.md @@ -0,0 +1,76 @@ +# 광역 위험 설명서 + +## 이 화면에서 하는 일 + +노선 띠(중심선 좌우 일정 폭) 안 자연 비탈의 얕은 산사태(표층 붕괴) 위험을 지도로 그림. +지형 격자 칸마다 **무한사면 안전율 Fs** 와 **SINMAP 안정 지수 SI** 를 셈 → SI 로 여섯 등급 색칠 → 측점마다 위험 정도(낮음 · 보통 · 높음)를 매김. + +- 지형 = 복사한 원지형(노선 공사 전 땅). 노선 위 · 아래 자연 비탈을 봄. +- 깎기 · 쌓기 비탈 자체는 2D 해석(E03) · 암반 해석(E06) 몫. + +## 작업 순서 + +1. 좌측 **토층 물성** 카드에서 물성(E02 지반 조건)을 고름. 고르면 γt · γsat · c · φ 칸이 채워짐(기본 = 붕적토). +2. 필요하면 뿌리 점착 · 토층 두께를 고침. +3. **시나리오 · 포화** 카드에서 시나리오를 고름 — 표준(SINMAP 습윤지수) 또는 최악(완전 포화). +4. **노선 띠 · 격자 · 측점 등급** 카드에서 띠 폭 · 격자 · 측점 문턱 둘을 확인함. +5. [해석] 을 누름. 진행 막대가 끝나면 지도 · 결과가 나옴. +6. 우측 **측점별 위험** 표에서 측점을 누르면 지도가 그 측점으로 옮겨 감. + +## 단추 + +| 단추 | 자리 | 하는 일 | +| --- | --- | --- | +| [기본값] | 좌 아래 | 모든 칸을 기본값으로 되돌림(해석은 안 함) | +| [다시 해석] | 좌 아래 | 같은 조건이라도 처음부터 다시 셈 | +| [해석] | 좌 아래 | 조건이 지난 결과와 같으면 「같은 조건의 결과가 있습니다.」 뒤 그 결과를 보임 · 다르면 새로 셈 | +| [등급 (SI)] [안전율 Fs] | 가운데 위 | 지도 색을 SI 등급 · 안전율로 바꿈 | + +- 칸 값은 이 탭 안에만 기억됨. 저장되는 것은 [해석] 한 결과(조건 포함) 하나뿐 — 새로 해석하면 지난 결과를 덮음. +- 보기만 허용된 프로젝트는 [해석] 이 잠김. + +## 결과 읽는 법 + +**가운데 · 지도** — 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 전체 맞춤. 옅은 띠 = 노선 띠 · 굵은 선 = 중심선 · 점 = 측점 · 오른쪽 위 = 북 · 오른쪽 아래 = 축척. +칸 위에 마우스를 올리면 등급 · SI · Fs · 경사 · 포화비 m · 가까운 측점이 보임. + +**범례 (등급 SI)** — 밑에 해석한 시나리오 이름이 붙음. + +| 색 | 등급 | SI | +| --- | --- | --- | +| 진초록 | 안정 | 1.5 이상 | +| 연두 | 보통 안정 | 1.25 ~ 1.5 | +| 연노랑 | 준안정 | 1.0 ~ 1.25 | +| 노랑 | 위험 하한 | 0.5 ~ 1.0 | +| 주황 | 위험 상한 | 0 초과 ~ 0.5 | +| 빨강 | 불안정 | 0 | + +- 위험 = 위험 하한 · 위험 상한 · 불안정(SI < 1). + +**범례 (안전율 Fs)** — 빨강 1.0 미만 · 주황 1.0 ~ 1.2 · 노랑 1.2 ~ 1.5 · 연두 1.5 ~ 2.0 · 초록 2.0 이상. 가장 나쁜 쪽(c · φ 아래 · 가장 젖음) 값임. + +**우측 · 광역 결과** + +- 요약 — 시나리오 · 띠 안 최소 Fs · 위험 면적(SI < 1) · 측점 등급(낮음 · 보통 · 높음 수 / 전체 측점) · 지형 있는 띠 / 띠 면적 · 해석 걸린 시간과 때. +- 등급별 면적 — 여섯 등급 면적(m²) · 비율(%). +- 측점별 위험 — 측점 · 최소 Fs · SI<1 비율(%) · 측점 등급 · 위험 m². 높음 줄은 강조됨. + +**측점 등급** — 측점 앞뒤 ½ 간격 × 띠 폭(영향 구역) 안 칸 중 SI < 1 칸 비율. + +| 측점 등급 | 기본 문턱 | +| --- | --- | +| 낮음 | 5 % 미만 | +| 보통 | 5 % 이상 20 % 미만 | +| 높음 | 20 % 이상 | + +문턱 둘은 좌측 「측점 보통 문턱」 · 「측점 높음 문턱」 칸에서 바꿈. + +**보고서(E05) 광역요약 시트** — E05 [보고서 갱신] 때 이 화면 저장 결과로 채움: 해석 조건(띠 · 물성 · 시나리오 · 해석 때) · 등급별 면적 · 측점 등급 표 · 보통 · 높음 측점 줄(측점 · 누가거리 · 최소 Fs · 최소 SI · 등급과 비율 · 위험 면적 · 최대 경사). 결과가 없으면 「광역 해석 전」. + +## 알아 둘 것 + +- **문헌 추정치 · 현장 시험 없음.** 물성 · T/R · 뿌리 점착 · 토층 두께 · 측점 문턱은 근거 후보 값임. 사용자 확정 전 값은 `30_사용자_확정_전_값` 참고. +- 위험 지도는 넓은 범위에서 「어디를 먼저 볼지」 고르는 용도임. 측점 단면 안전율(E03)을 대신하지 않음. +- 지형 격자 끝 밖 상류는 집수면적에 안 들어감 — 띠 가장자리는 실제보다 마르게(안전하게) 나올 수 있음. +- 「엔진이 바뀌어 결과가 옛것입니다」가 뜨면 [다시 해석] 할 것. +- 최악(완전 포화)은 모든 칸이 물에 찬 극한 강우 가정 — 대부분 측점이 높음으로 나옴. 비교용으로 쓸 것. diff --git a/resources/help/E07/10_칸_뜻.md b/resources/help/E07/10_칸_뜻.md new file mode 100644 index 000000000..4f1a8a36a --- /dev/null +++ b/resources/help/E07/10_칸_뜻.md @@ -0,0 +1,56 @@ +# 칸 뜻 + +범위 밖 값으로 [해석] 을 누르면 까닭이 알림으로 뜨고 해석하지 않음. 「위」 칸은 「아래」 칸보다 작으면 안 됨(c · φ · T/R) · 높음 문턱은 보통 문턱보다 작으면 안 됨. + +## 토층 물성 (좌 · 첫 카드) + +토층 = 기반암 위에 덮인 흙. 무한사면 식이 쓰는 값. + +| 칸 | 뜻 | 범위 | 기본 | +| --- | --- | --- | --- | +| 물성(E02 지반 조건) | 지반 조건(E02) 물성 목록에서 고름 · 고르면 아래 여섯 칸을 채움(이름표일 뿐 · 값은 아래 칸) | - | 붕적토 | +| γt (kN/m³) | 젖지 않은 흙 단위중량 | 0 초과 ~ 30 | 17.5 | +| γsat (kN/m³) | 물에 찬 흙 단위중량 | 9.81 초과 ~ 30 | 18.5 | +| c 아래 (kPa) | 점착력 범위 아래 끝 | 0 ~ 500 | 5 | +| c 위 (kPa) | 점착력 범위 위 끝(물성을 고르면 아래 + 5) | 0 ~ 500 | 10 | +| φ 아래 (°) | 내부 마찰각 범위 아래 끝 | 0 ~ 60 | 27 | +| φ 위 (°) | 내부 마찰각 범위 위 끝(물성을 고르면 아래 + 3) | 0 ~ 60 | 30 | +| 뿌리 점착 (kPa) | 나무뿌리가 흙을 붙잡는 힘 · c 에 더함 · 나지 · 절성토면 = 0 | 0 ~ 50 | 1 | +| 토층 두께 (m) | 미끄러질 수 있는 흙 두께(지표에서 수직) | 0.1 ~ 10 | 1.5 | + +- 범위(아래 ~ 위)는 SI 계산에 씀. Fs 는 아래 끝 값(보수 쪽)으로 셈. + +## 시나리오 · 포화 (좌 · 둘째 카드) + +| 칸 | 뜻 | 범위 | 기본 | +| --- | --- | --- | --- | +| 시나리오 | 표준 — SINMAP 습윤지수 (T/R 범위) · 최악 — 완전 포화 (m = 1 · 강우 극한) | - | 표준 | +| T/R 아래 (m · 젖은 쪽) | 투수량계수 T ÷ 유효 강우 R · 작을수록 물이 잘 고임 | 1 ~ 100,000 | 2,000 | +| T/R 위 (m · 마른 쪽) | 같은 값의 범위 위 끝 | 1 ~ 100,000 | 3,000 | + +- T/R 두 칸은 표준일 때만 보임. +- 포화비 m = 토층 두께 중 물에 찬 비율(0 = 마름 · 1 = 지표까지 참). + +## 노선 띠 · 격자 · 측점 등급 (좌 · 셋째 카드) + +| 칸 | 뜻 | 범위 | 기본 | +| --- | --- | --- | --- | +| 띠 폭 ± (m) | 중심선에서 좌우로 볼 거리 · 지도 옅은 띠가 바로 바뀜 | 10 ~ 500 | 100 | +| 격자 (m) | 계산 칸 한 변 · 작을수록 촘촘하고 오래 걸림 | 1 ~ 10 | 2 | +| 측점 보통 문턱 (SI<1 비율 %) | 영향 구역 안 SI < 1 칸 비율이 이 값 이상이면 보통 | 0 초과 ~ 100 | 5 | +| 측점 높음 문턱 (SI<1 비율 %) | 이 값 이상이면 높음 | 0 초과 ~ 100 | 20 | + +## 결과 칸 (우측) + +| 칸 | 뜻 | +| --- | --- | +| 띠 안 최소 Fs | 띠 안 모든 칸 중 가장 낮은 안전율 | +| 위험 면적 (SI < 1) | 위험 하한 · 위험 상한 · 불안정 등급 면적 합 | +| 지형 있는 띠 / 띠 | 지형 자료가 있는 띠 면적 / 띠 전체 면적 · 차이 = 지형 밖이라 못 본 곳 | +| 최소 Fs (측점 표) | 그 측점 영향 구역 안 가장 낮은 안전율 | +| SI<1 비율 (%) | 영향 구역 칸 중 SI < 1 칸 비율 | +| 위험 m² | 영향 구역 안 SI < 1 면적 | +| 툴팁 m | 그 칸 포화비(가장 젖은 쪽) · 최악 시나리오는 1 | + +- 영향 구역 = 띠 안 칸을 가장 가까운 중심선 누가거리로 나눈 뒤, 이웃 측점과 가운데에서 끊은 몫(측점 앞뒤 ½ 간격 × 띠 폭). +- 영향 구역에 지형이 없는 측점은 값이 「-」. diff --git a/resources/help/E07/20_쓰인_방법과_근거.md b/resources/help/E07/20_쓰인_방법과_근거.md new file mode 100644 index 000000000..a3af805f3 --- /dev/null +++ b/resources/help/E07/20_쓰인_방법과_근거.md @@ -0,0 +1,69 @@ +# 쓰인 방법과 근거 + +## 근거 문서 + +- 지식DB `예정노선/02` §1.1 무한사면 안전율 식(포화비 · 뿌리 점착 포함) · §2 ① 지형습윤지수 + 정상류 무한사면(SINMAP · SHALSTAB). +- Pack 외(1998) SINMAP 안정 지수 방법 · SINMAP 2 설명서 기본 보정(T/R 2,000 ~ 3,000 m) — 식만 따름 · 원 프로그램은 쓰지 않음. +- 지식DB `안정성평가/04` · `07` — 붕적토 물성 범위(지반 조건 E02 기본값의 출처). +- 국내 적용 연구 — 뿌리 점착(산림 0 ~ 5 kPa · 우면산 1 kPa). + +## 계산 차례 + +1. 원지형 격자를 「격자」 간격으로 다시 고름. +2. 칸마다 경사 β — 이웃 3 × 3 칸으로 구함(Horn 방법). +3. 칸마다 단위 집수면적 a — 웅덩이를 채운 뒤 물이 가장 가파른 이웃으로 흐른다고 보고(D8) 위쪽에서 모여드는 칸 수 × 칸 크기. 배수유역(B04)과 같은 흐름 계산. +4. 칸마다 포화비 m(시나리오별) → 안전율 Fs · 안정 지수 SI → 등급. +5. 노선 띠 안 칸만 남겨 등급별 면적 · 측점 표를 만듦. + +## 포화비 m (시나리오) + +- **표준 — SINMAP 습윤지수**: m = (R ÷ T) × a ÷ sinβ, 단 1 을 넘으면 1. + - 위쪽 모이는 넓이 a 가 크고 경사가 완만할수록 잘 젖음(골짜기 · 비탈 아래). + - T/R 범위 안에서 바뀜 · Fs 지도는 가장 젖은 쪽(T/R 아래)으로 셈. +- **최악 — 완전 포화**: 모든 칸 m = 1(지표까지 물에 참). 극한 강우 비교용. + +## 무한사면 안전율 Fs + +비탈과 나란한 얇은 흙층이 통째로 미끄러진다고 보는 식. + +안전율 = 버티는 힘 ÷ 미는 힘 +- 버티는 힘 = (c + 뿌리 점착) + [γt × z × (1 − m) + (γsat − γw) × m × z] × cos²β × tanφ +- 미는 힘 = [γt × z × (1 − m) + γsat × m × z] × sinβ × cosβ +- z = 토층 두께 · γw = 물 단위중량 9.81 kN/m³. +- 물이 차면(m 이 커지면) 흙을 누르는 힘이 줄어 마찰이 약해짐 → Fs 가 낮아짐. +- 거의 평평한 칸은 미끄러지지 않는다고 봄(Fs · SI 무한대 = 안정). + +## 안정 지수 SI + +c · φ · T/R 을 정확히 모르니 범위(아래 ~ 위) 안에서 고르게 퍼져 있다고 보고 셈. + +- 가장 나쁜 조합(c · φ 아래 · 가장 젖음)에서도 Fs ≥ 1 → SI = 그 Fs(1 이상 · 클수록 안정). +- 가장 좋은 조합(c · φ 위 · 가장 마름)에서도 Fs < 1 → SI = 0(불안정). +- 그 사이 → SI = Fs > 1 일 확률(0 ~ 1). 예: SI 0.7 = 범위 안 조합의 70 % 가 버팀. +- 최악 시나리오는 m = 1 고정이라 c · φ 범위만 봄. + +| 등급 | SI | 쉬운 말 | +| --- | --- | --- | +| 안정 | 1.5 이상 | 나쁜 조합에서도 넉넉히 버팀 | +| 보통 안정 | 1.25 ~ 1.5 | 나쁜 조합에서도 버팀 | +| 준안정 | 1.0 ~ 1.25 | 나쁜 조합에서 겨우 버팀 | +| 위험 하한 | 0.5 ~ 1.0 | 조합 절반 이상은 버팀 | +| 위험 상한 | 0 초과 ~ 0.5 | 조합 절반 넘게 무너짐 | +| 불안정 | 0 | 좋은 조합에서도 무너짐 | + +등급 경계 = SINMAP 원 방법 그대로. + +## 측점 등급 + +- 영향 구역 = 측점 앞뒤 ½ 간격 × 띠 폭(띠 칸을 가장 가까운 중심선 누가거리로 나눔). +- 영향 구역 칸 중 SI < 1(위험 하한 이상) 칸 비율로 낮음 · 보통 · 높음(문턱 기본 5 % · 20 %). +- 한 칸이라도 위험이면 위험으로 잡던 방식은 거의 모든 측점이 위험으로 나와 변별력이 없어 비율 방식을 씀. + +## 한계 + +- 문헌 추정치 · 현장 시험 없음. 토층 두께 · 물성 · T/R 이 띠 전체에 한 값임(칸마다 다르지 않음). +- 얕은 평행 미끄러짐만 봄. 깊은 원호 활동 · 암반 무너짐 · 토석류 흘러감은 안 봄. +- 지형 = 원지형. 깎기 · 쌓기로 바뀐 땅은 반영 안 됨. +- 지형 격자 끝 밖 상류는 모름 — 띠 가장자리 칸은 집수면적이 작게 나와 실제보다 마르게 셈. +- 강우량(mm) 직접 입력 없음 — 젖는 정도는 T/R 범위로만 넣음. +- 산림청 위험지도 등급 비율 공개 자료가 없어 그와 대조하지 못함. 시험 프로젝트 위험(SI < 1) 비율 11.1 % 가 국내 SINMAP 적용 사례(약 7.5 ~ 16 %) 범위 안임만 확인함. diff --git a/resources/help/E07/30_사용자_확정_전_값.md b/resources/help/E07/30_사용자_확정_전_값.md new file mode 100644 index 000000000..df1a785b9 --- /dev/null +++ b/resources/help/E07/30_사용자_확정_전_값.md @@ -0,0 +1,20 @@ +# 사용자 확정 전 값 + +아래 값은 문헌 · 연구에서 고른 근거 후보임. 화면에서 바꿀 수 있고, 기본값으로 굳힐지는 사용자 협의로 정함. 바꾸면 그 값으로 [해석] 하면 됨 — 기본값 자체는 바뀌지 않음. + +| 값 | 지금 기본값 | 출처 | 상태 | +| --- | --- | --- | --- | +| 뿌리 점착 | 1 kPa | 국내 적용 연구 — 산림 0 ~ 5 kPa · 우면산 1 kPa · 나지 · 절성토면 0 | 사용자 확정 전 | +| 측점 보통 문턱 | SI<1 비율 5 % | 개발 쪽에서 정함 · 시험 프로젝트 측점 SI<1 비율 분포(중앙 약 10 %)를 보고 고름 | 사용자 확정 전 | +| 측점 높음 문턱 | SI<1 비율 20 % | 위와 같음 | 사용자 확정 전 | +| 토층 물성 | E02 붕적토 — γt 17.5 · γsat 18.5 kN/m³ · c 5 kPa · φ 27° | 지식DB `안정성평가/07` 붕적토(γt 17 ~ 18 · c 0 ~ 10 · φ 25 ~ 28) · E02 기본값 | 문헌 추정치 · 사용자 확정 전 | +| c · φ 범위 폭 | c + 5 kPa · φ + 3° (c 5 ~ 10 · φ 27 ~ 30) | 지식DB `안정성평가/04` 붕적토 범위 폭의 반 | 사용자 확정 전 | +| T/R 범위 | 2,000 ~ 3,000 m | SINMAP 2 설명서 기본 보정 | 문헌 기본 · 현장 보정 없음 | +| 토층 두께 | 1.5 m | 지식DB `예정노선/02` §1 표층 붕괴 토층 1 ~ 3 m | 사용자 확정 전 | +| 노선 띠 폭 | 중심선 ± 100 m | 요구 분석(노선 좌우 100 m 예) | 사용자 확정 전 | +| 격자 | 2 m | 프로그램 기본값 · 근거 문서 없음 | 사용자 확정 전 | +| 기본 시나리오 | 표준(SINMAP 습윤지수) | SINMAP 표준 방법 · 최악(완전 포화)은 선택지 | 정함 | + +- 물성 기본은 그 프로젝트 지반 조건(E02)의 붕적토 값을 따름 — E02 에서 붕적토를 고치면 이 화면 기본값도 바뀜. +- 화면 좌측 카드 아래 안내 글(기본값 = 근거 후보 …)에 같은 출처가 한 줄로 적혀 있음. +- 등급 경계(SI 1.5 · 1.25 · 1.0 · 0.5 · 0)는 SINMAP 원 방법 값이라 이 표에 넣지 않음. diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py index 1bc48b728..b5ae4fc75 100644 --- a/resources/tester/test_54_2_help_files.py +++ b/resources/tester/test_54_2_help_files.py @@ -1,5 +1,6 @@ """54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜).""" +import re import tempfile from pathlib import Path @@ -28,15 +29,30 @@ def test_title_order_and_live_edit(): assert "고친 글" in read_help("B05", tmp_path)[1]["text"] -@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05", "D05", "E06", "C01"]) +@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05", "D05", "E08", "C01"]) def test_bad_page_refused(page): with pytest.raises(HTTPException): read_help(page) def test_529_1_D_E_pages_open_empty(tmp_path): - assert read_help("D02", tmp_path) == [] and read_help("E05", tmp_path) == [] + assert read_help("D02", tmp_path) == [] and read_help("E07", tmp_path) == [] def test_root_is_repo_resources(): assert HELP_ROOT.parts[-2:] == ("resources", "help") + + +@pytest.mark.parametrize( + "page", ["D01", "D02", "D03", "D04", "E01", "E02", "E03", "E04", "E05", "E06", "E07"] +) +def test_d_e_pages_have_manual(page): + items = read_help(page) + assert items and items[0]["key"] == "00_설명서" + assert len(items[0]["text"]) > 400 + for item in items: # 명사형 종결 — 합쇼체 · 해요체 · 한다체 금지 + for line in item["text"].splitlines(): + assert not re.search(r"(합니다|습니다|해요|세요|한다|된다|있다|없다)[.!]?\s*$", line), ( + page, + line, + ) From 35f40d2083a28a10a3e417a152d4f1aa2ecdf98c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 00:54:07 +0900 Subject: [PATCH 34/34] =?UTF-8?q?feat(534-5):=20E05=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=ED=8F=89=EA=B0=80=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EA=B8=B0=20=E2=80=94=20=EB=AA=A9=EC=B0=A8(?= =?UTF-8?q?=EC=AA=BD=20=3D=20=EC=8B=A4=EC=A0=9C=20PDF=20=EC=AA=BD)=20?= =?UTF-8?q?=C2=B7=20=EB=A8=B8=EB=A6=AC=EB=A7=90=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EB=AA=85=20=C2=B7=20=EA=BC=AC=EB=A6=AC=EB=A7=90=20n=20/=20N(?= =?UTF-8?q?=ED=91=9C=EC=A7=80=20=EB=BA=8C)=20=C2=B7=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=ED=91=9C=20=3D=20=ED=8C=90=EC=A0=95=20=EC=9D=BC=EB=9E=8C?= =?UTF-8?q?=ED=91=9C(=EB=8C=80=EC=B1=85=20=ED=95=84=EC=9A=94=20=EC=97=B4?= =?UTF-8?q?=20=C2=B7=20=EC=AA=BD=EB=A7=88=EB=8B=A4=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=EB=82=98=EB=88=94)=20=C2=B7=20=EB=8C=80=ED=91=9C?= =?UTF-8?q?=20=EB=8B=A8=EB=A9=B4=20=EA=B7=B8=EB=A6=BC(=EC=95=88=EC=A0=84?= =?UTF-8?q?=EC=9C=A8=20=EC=83=81=EC=9E=90=20=C2=B7=20=EC=A7=80=EC=B8=B5=20?= =?UTF-8?q?=EB=AC=B4=EB=8A=AC=20=EB=B2=94=EB=A1=80=20=C2=B7=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0=20=EC=A0=90=20=C2=B7=20=EC=B6=95=EC=B2=99=20=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=C2=B7=20=ED=95=B4=EC=84=9D=EC=97=90=20=EC=93=B4=20?= =?UTF-8?q?=EB=84=93=ED=9E=8C=20=EC=A7=80=EB=B0=98=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=ED=9D=91=EB=B0=B1=20=EC=84=A0=20=EB=AA=A8=EC=96=91=20=C2=B7=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=3D=20=EC=97=94=EC=A7=84=20=EA=B0=92)=20?= =?UTF-8?q?=C2=B7=20=EB=B6=80=EB=A1=9D=20=EC=A0=88=ED=8E=B8=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=ED=91=9C(=EB=8C=80=ED=91=9C=20=EB=8B=A8=EB=A9=B4?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=A7=80=EB=B0=B0=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=ED=95=9C=20=EC=AA=BD)=20=C2=B7=20=E3=80=8C=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=99=95=EC=A0=95=20=EC=A0=84=E3=80=8D=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20=ED=95=9C=20=EA=B7=9C=EC=B9=99(=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EC=95=88=EC=A0=84=EC=9C=A8=20=C2=B7=20=EB=85=B8=EB=A9=B4=20?= =?UTF-8?q?=ED=95=98=EC=A4=91=20=C2=B7=20=EC=9A=B0=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=ED=95=98=EC=88=98=20=EA=B0=88=EB=9E=98=20=C2=B7=20=EB=AC=BC?= =?UTF-8?q?=EC=84=B1=20=C2=B7=20=EA=B4=91=EC=97=AD=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EC=A4=84)=20=C2=B7=20=ED=91=9C=EC=A7=80=20?= =?UTF-8?q?=EA=BE=B8=EB=B0=88=20=C2=B7=20=EC=98=9B=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EB=B3=B8=20=EA=B0=B1=EC=8B=A0=20=3D=20=EB=AA=A9=EC=B0=A8=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=C2=B7=20=EC=84=9C=EC=8B=9D=20=EB=B6=99?= =?UTF-8?q?=EC=9E=84=20=C2=B7=20Build=20=EB=82=98=EB=88=94(Cells=20=C2=B7?= =?UTF-8?q?=20Section=20=C2=B7=20Print)=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20PD?= =?UTF-8?q?F=20=EC=97=94=EC=A7=84=EC=97=90=20=EB=A8=B8=EB=A6=AC=EB=A7=90?= =?UTF-8?q?=20=C2=B7=20=EA=BC=AC=EB=A6=AC=EB=A7=90=20=EC=B9=B8=20=EB=8D=A7?= =?UTF-8?q?=EB=B6=99=EC=9E=84(=EA=B8=B0=EB=B3=B8=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C)(=EC=9A=94=EA=B5=AC=20347-5=20?= =?UTF-8?q?=C2=B7=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시험 고침: 534-3 양식 시험의 시트 목록 · 쪽 수 기대(목차 · 부록 더함) · E05 시험 시트 수 7 → 9 · PDF = report_pdf(목차 쪽 번호 = 실제 · 꼬리말 · 머리말) · 부록 절편 · 측점표 쪽 나눔 · 옛 작업본 갱신 시험 덧. Co-Authored-By: Claude Opus 5.5 --- E05_StabReport/E05_StabReport_Area.py | 34 +- E05_StabReport/E05_StabReport_Build.py | 330 +++-------- E05_StabReport/E05_StabReport_Cells.py | 98 ++++ E05_StabReport/E05_StabReport_Figure.py | 248 +++++--- E05_StabReport/E05_StabReport_Print.py | 229 ++++++++ E05_StabReport/E05_StabReport_Router.py | 7 +- E05_StabReport/E05_StabReport_Section.py | 318 ++++++++++ E05_StabReport/E05_StabReport_Store.py | 41 +- common_util/common_util_print_pdf.py | 10 +- .../master_template/report/stab_report.json | 550 +++++++++++++++++- .../report/stab_report_slots.md | 42 +- resources/tester/test_534_3_e05_report.py | 98 +++- .../tester/test_534_3_stab_report_form.py | 15 +- ui_template/ui_template_locale_e05.ts | 4 +- 14 files changed, 1622 insertions(+), 402 deletions(-) create mode 100644 E05_StabReport/E05_StabReport_Cells.py create mode 100644 E05_StabReport/E05_StabReport_Print.py create mode 100644 E05_StabReport/E05_StabReport_Section.py diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py index 810d7c93e..87633ad7a 100644 --- a/E05_StabReport/E05_StabReport_Area.py +++ b/E05_StabReport/E05_StabReport_Area.py @@ -9,8 +9,10 @@ from __future__ import annotations from pathlib import Path from typing import Any +from E02_StabGround.E02_StabGround_Model import load_defaults +from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS -from E07_StabArea.E07_StabArea_Router import RESULT, load +from E07_StabArea.E07_StabArea_Router import RESULT, AreaRequest, load LEVEL_NAMES = {"low": "낮음", "mid": "보통", "high": "높음"} @@ -49,6 +51,35 @@ def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) - sheet["칸"][a1] = cell +UNCONFIRMED_KEYS = ( # (설정 칸, 글) — 기본값 그대로면 사용자 확정 전 + ("c_root", "뿌리 점착 {c_root:g} kPa"), + ("soil_depth_m", "토층 {soil_depth_m:g} m"), + ("tr_lo", "T/R {tr_lo:g}~{tr_hi:g} m"), + ("level_lo_pct", "측점 문턱 {level_lo_pct:g} % · {level_hi_pct:g} %"), +) +PAIRS = {"tr_lo": "tr_hi", "level_lo_pct": "level_hi_pct"} + + +def _unconfirmed(s: dict[str, Any]) -> str: + """기본값 그대로인 설정 · 물성(E02 붕적토 문헌 추정치) — 사용자 확정 전 한 줄.""" + fields = AreaRequest.model_fields + same = [ + text.format(**s) + for key, text in UNCONFIRMED_KEYS + if all( + k in s and float(s[k]) == float(fields[k].default) for k in (key, PAIRS.get(key, key)) + ) + ] + base = {m["id"]: m for m in load_defaults()["materials"]}.get( + s.get("material_id") or "colluvium" + ) + if base and all( + float(s.get(k, -1)) == float(base[k]) for k in ("gamma_t", "gamma_sat", "c", "phi") + ): + same.insert(0, f"토층 물성 = {base['name']} 문헌 추정치") + return f"{' · '.join(same)} — {UNCONFIRMED}" if same else "없음(모두 사용자 입력)" + + def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None: """s7 — 조건 넷 · 등급별 면적 여섯 · 측점 등급별 수 · 보통 · 높음 측점(22 행부터 한 줄씩).""" if not area: @@ -68,6 +99,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f ) _set(sheet, "B7", scenario_text(s)) _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) + _set(sheet, "B9", _unconfirmed(s)) for k in range(1, 7): _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) lo, hi = s["level_lo_pct"], s["level_hi_pct"] diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index 9529db131..241c49336 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -1,15 +1,16 @@ -"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3). +"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3 · 534-5). 입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 -`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) · E07 광역 저장본(있으면) · -E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) · +`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약 +(있으면) · E07 광역 저장본(있으면) · E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) · E01 복사본(측점 · 횡단 · 노선). 양식 = `resources/master_template/report/stab_report.json` 칸 자리 = 같은 폴더 `stab_report_slots.md`. 몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 · -프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8) -= [보고서 갱신] 때 양식에서 통째로 다시 씀. +프로그램(목차 s9 · 입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8 · +부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` · +목차 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔 = `_Print` · 칸 공용 = `_Cells`. 판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 (인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). @@ -20,7 +21,6 @@ storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py` from __future__ import annotations -import copy import hashlib import json import re @@ -32,41 +32,37 @@ from common_util.common_util_derived_store import read_derived from E01_StabRegister import E01_StabRegister_Source as source from E02_StabGround import E02_StabGround_Model as ground_model from E03_StabSection import E03_StabSection_View as view2d -from E03_StabSection.E03_StabSection_Engine_Model import final_surface from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides from E04_Stab3D import E04_Stab3D_Run as run3d from E05_StabReport import E05_StabReport_Area as area from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport import E05_StabReport_Print as printer from E05_StabReport import E05_StabReport_Reinforce as reinforce +from E05_StabReport import E05_StabReport_Section as section +from E05_StabReport.E05_StabReport_Cells import ( # noqa: F401 — 시험 · 다른 모듈이 build.* 로 씀 + A1, + SIDE_NAMES, + ST_NG, + ST_OK, + _copy_row, + _criterion, + _label_at, + _labels, + _seismic, + _set, + _verdict, + fig_key, + mark, +) ROOT = Path(__file__).resolve().parents[1] FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json" STAGE = "E05_StabReport" REPORT, META = "report.json", "report_meta.json" USER_SHEETS = ("s1", "s2") -PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7", "s8") -BLOCK = 28 # 대표단면 한 쪽 = 28 행 +PROGRAM_SHEETS = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s10") REPRESENTATIVE = Path("E03_StabSection") / "representative.json" BAND_KEY = "fig/3d_band.png" -LOWEST_N = view2d.REPRESENTATIVE_LOWEST -CASES = ("dry", "wet", "seismic") -SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"} -METHOD_NAMES = { - "bishop": "Bishop", - "janbu": "Janbu 보정", - "janbu_corrected": "Janbu 보정", - "ordinary": "Fellenius", - "spencer": "Spencer", -} -REASONS = { - "fill_height": "쌓기 높이 10 m 이상", - "cut_height": "깎기 높이 15 m 이상", - "lowest": "최소 안전율 하위", - "user": "사용자 지정", -} -GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"} -ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17 -A1 = re.compile(r"^([A-Z]+)(\d+)$") class MissingInput(Exception): @@ -86,10 +82,6 @@ def initial_dir(project_root: Path) -> Path: return Path(project_root) / "initial_snapshot" / STAGE -def fig_key(station_key: str) -> str: - return f"fig/{station_key}.png" - - def fig_path(folder: Path, key: str) -> Path: """그림 열쇠 → 파일(윈도우 못 쓰는 글자 `:` 등은 `_`).""" name = key.removeprefix("fig/") @@ -108,9 +100,10 @@ def _ground(project_root: Path) -> dict[str, Any]: ) -def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None: - saved = read_derived(Path(project_root), KIND_STATION, fp) - return (saved or {}).get("value", {}).get("out") +def _station_result(project_root: Path, fp: str) -> tuple[dict[str, Any] | None, list | None]: + """측점 저장본 — (결과, 해석에 쓴 넓힌 지반선 · 없으면 None).""" + value = (read_derived(Path(project_root), KIND_STATION, fp) or {}).get("value") or {} + return value.get("out"), (value.get("inputs") or {}).get("ground") def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]: @@ -204,7 +197,7 @@ def gather(project_root: Path) -> dict[str, Any]: reps = _representatives(r2) for item in reps: chainage = float(item["summary"]["chainage_m"]) - item["result"] = _station_result(project_root, item["summary"]["fp"]) + item["result"], item["ground"] = _station_result(project_root, item["summary"]["fp"]) try: item["cross"] = source.cross_section(root, chainage) except KeyError: @@ -229,63 +222,6 @@ def gather(project_root: Path) -> dict[str, Any]: # ── 칸 쓰기 ────────────────────────────────────────────────────────────────── -def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: - """값(None = 지움) · 서식(None = 양식 그대로).""" - cell = dict(sheet["칸"].get(a1) or {}) - cell.pop("식", None) - if value is None or value == "": - cell.pop("값", None) - else: - cell["값"] = round(value, 6) if isinstance(value, float) else value - if style is not None: - cell["서식"] = style - if cell: - sheet["칸"][a1] = cell - else: - sheet["칸"].pop(a1, None) - - -def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None: - for col in cols: - cell = sheet["칸"].get(f"{col}{src}") - if cell and dst != src: - sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {} - - -def _shift(a1: str, dr: int) -> str: - m = A1.match(a1) - return f"{m.group(1)}{int(m.group(2)) + dr}" - - -def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: - if ok is None: - _set(sheet, a1, None) - else: - _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) - - -def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float: - sf = (ground.get("safety_factors") or {}).get(case) or {} - if side in ("cut", "fill"): - return float(sf[side]) - return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽 - - -def _seismic(ground: dict[str, Any]) -> bool: - return bool((ground.get("seismic") or {}).get("enabled")) - - -def _labels(stations: list[dict[str, Any]]) -> dict[float, str]: - return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations} - - -def _label_at(labels: dict[float, str], chainage: float) -> str: - if not labels: - return f"{chainage:.2f}" - near = min(labels, key=lambda c: abs(c - chainage)) - return labels[near] or f"{chainage:.2f}" - - # ── 사용자 몫(처음 한 번) ──────────────────────────────────────────────────── def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: """표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀.""" @@ -307,9 +243,30 @@ WATER_TEXT = { } +def _wet_text(water: dict[str, Any], base: dict[str, Any]) -> str: + """우기 지하수 갈래 글(E02 `wet_mode`) · 기본값이면 사용자 확정 전 표기.""" + mode = water.get("wet_mode") or "kds" + if mode == "depth_ratio": + ratio = float(water["depth_ratio"]) + return mark(f"지표 아래 깊이 = 비탈 높이 × {ratio:g}", ratio == float(base["depth_ratio"])) + if mode == "ru": + ru = float(water["ru"]) + return mark(f"간극수압비 r_u = {ru:g}", ru == float(base["ru"])) + kds = ( + f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · " + f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}" + ) + if mode == "line": + return f"사용자 수위선 {len(water.get('lines') or [])} 구간 · 구간 밖 = {kds} (사용자 입력)" + same = all(water.get(k) == base.get(k) for k in ("wet_soil", "wet_rock")) + return mark(f"KDS 기본 — {kds}", same) + + def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: """s3 입력조건.""" ground = inputs["ground"] + base = ground_model.load_defaults() + base_sf = base["safety_factors"] _set(sheet, "B5", "한계평형해석 — Bishop 간편법(기본) · Janbu 보정 · Fellenius 비교") _set(sheet, "B6", "원호(격자 · 진입-탈출) + 비원호(꺾은선) · 측점마다 좌 · 우 비탈 따로") _set( @@ -326,16 +283,14 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: _set(sheet, f"C{r}", "지진 검토 안 함") continue sf = ground["safety_factors"][case] + sf_base = base_sf.get(case) or {} + same = all(float(sf_base.get(k, -1)) == float(sf[k]) for k in ("cut", "fill")) _set(sheet, f"B{r}", _criterion(ground, case)) - _set(sheet, f"C{r}", f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽") + text = f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽 · KDS 11 70 05" + _set(sheet, f"C{r}", mark(text, same)) water = ground.get("groundwater") or {} _set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-")) - _set( - sheet, - "B18", - f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · " - f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}", - ) + _set(sheet, "B18", _wet_text(water, base["groundwater"])) seismic = ground.get("seismic") or {} _set( sheet, @@ -344,8 +299,11 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: if _seismic(ground) else "적용 안 함", ) - _set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0)) - defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]} + load = float(ground.get("road_surcharge_kpa") or 0) + _set(sheet, "B22", load) + default_load = float(base["road_surcharge_kpa"]) + _set(sheet, "D22", mark("옹벽 배면 상재하중 준용", load == default_load)) + defaults = {m["id"]: m for m in base["materials"]} names = {m["id"]: m["name"] for m in ground.get("materials") or []} mapped = set((ground.get("label_map") or {}).values()) # 지반 라벨에 이은 지층만 「전체」 rows: list[tuple[dict[str, Any], str]] = [ @@ -374,7 +332,9 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: float(base[f]) == float(m.get(f, -1)) for f in ground_model.MATERIAL_FIELDS ) _set( - sheet, f"G{r}", ("문헌 추정치(기본값)" if same else "사용자 입력") if "c" in m else None + sheet, + f"G{r}", + ("문헌 추정치 · 사용자 확정 전" if same else "사용자 입력") if "c" in m else None, ) @@ -387,7 +347,7 @@ def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int: ng = 0 for i, st in enumerate(rows): r = 6 + i - _copy_row(sheet, 6, r, "ABCDEFGHIJ") + _copy_row(sheet, 6, r, "ABCDEFGHIJK") side = st.get("side") fs = st.get("fs_by_condition") or {} _set(sheet, f"A{r}", _label_at(labels, float(st["chainage_m"]))) @@ -402,160 +362,10 @@ def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int: ok = None if verdict not in ("ok", "ng") else verdict == "ok" ng += ok is False _verdict(sheet, f"J{r}", ok) + _set(sheet, f"K{r}", None if ok is None else "-" if ok else "대책 검토") return ng -def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str): - """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이.""" - best = None - for case in (result or {}).get("cases") or []: - if case.get("condition") != condition: - continue - if side in ("cut", "fill") and case.get("role") not in (side, None): - continue - methods = (case.get("result") or {}).get("methods") or {} - found = (methods.get(method) or {}).get("min") or next( - (v["min"] for v in methods.values() if v.get("min")), None - ) - if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]): - best = (found, case) - return best - - -def _block_copy(sheet: dict[str, Any], template: dict[str, Any], k: int) -> None: - """양식 1~28 행 덩이를 k 번째 자리로(칸 · 병합 · 행 높이 · 조건부서식).""" - dr = BLOCK * k - for a1, cell in template["칸"].items(): - if int(A1.match(a1).group(2)) <= BLOCK: - sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell) - for m in template.get("병합", []): - sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":"))) - for r, info in (template.get("행") or {}).items(): - if int(r) <= BLOCK: - sheet["행"][str(int(r) + dr)] = dict(info) - for rule in template.get("조건부서식", []): - moved = copy.deepcopy(rule) - moved["id"] = f"{rule['id']}_{k}" - moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]] - sheet["조건부서식"].append(moved) - - -CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" - - -def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]: - """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)].""" - template = copy.deepcopy(sheet) - reps = inputs["representatives"] - ground = inputs["ground"] - labels = _labels(inputs["stations"]) - sheet["칸"] = {} - sheet["병합"], sheet["행"], sheet["조건부서식"], sheet["그림"] = [], {}, [], [] - if not reps: - _block_copy(sheet, template, 0) - _set(sheet, "A1", "대표 단면 — 해당 없음") - sheet["인쇄"]["쪽나눔"] = [] - return [] - jobs: list[tuple[str, str]] = [] - for k, rep in enumerate(reps): - dr = BLOCK * k - _block_copy(sheet, template, k) - st = rep["summary"] - side = st.get("side") - method = st.get("method") or "bishop" - label = _label_at(labels, float(st["chainage_m"])) - picks = {c: _case_slip(rep.get("result"), c, side, method) for c in CASES} - dry = picks.get("dry") - height = float(dry[1].get("height_m") or 0) if dry else 0.0 - mark = CIRCLED[k] if k < len(CIRCLED) else str(k + 1) - _set( - sheet, - f"A{1 + dr}", - f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m", - ) - reason = REASONS.get(rep.get("reason"), rep.get("reason") or "") - if rep.get("reason") == "lowest": - reason = f"{reason} {LOWEST_N}" - _set(sheet, f"B{2 + dr}", reason) - key = fig_key(rep["station_key"]) - sheet["그림"].append( - {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"} - ) - fs = st.get("fs_by_condition") or {} - slips = [] - for r, case in zip((24, 25, 26), CASES): - row = r + dr - if case == "seismic" and not _seismic(ground): - continue - value = fs.get(case) - crit = _criterion(ground, case, side) - pick = picks.get(case) - _set(sheet, f"B{row}", METHOD_NAMES.get(method, method)) - _set(sheet, f"C{row}", float(value) if value is not None else None) - _set(sheet, f"D{row}", crit) - _verdict(sheet, f"E{row}", None if value is None else float(value) >= crit - 1e-9) - slip = pick[0] if pick else None - center = (slip or {}).get("center") - _set(sheet, f"F{row}", float(slip["radius"]) if slip and slip.get("radius") else None) - _set(sheet, f"G{row}", float(center[0]) if center else None) - _set(sheet, f"H{row}", float(center[1]) if center else None) - if slip and slip.get("points"): - slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]}) - design = (rep.get("cross") or {}).get("design") or {} - kind = (dry[0].get("kind") if dry else None) or "" - layers = rep.get("layers") or {} - note = [ - {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""), - f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}", - f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "", - ] - _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n)) - jobs.append((key, _section_figure(rep, slips, f"{label} — {reason}"))) - sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))] - return jobs - - -def _section_figure(rep: dict[str, Any], slips: list[dict[str, Any]], title: str) -> str: - cross = rep.get("cross") or {} - design = cross.get("design") or {} - ground = sorted( - (float(s["offset_m"]), float(s["z"])) - for s in cross.get("samples") or [] - if s.get("valid", True) and s.get("z") is not None - ) - line = sorted( - (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] - ) - surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground - thickness = (rep.get("layers") or {}).get("soil_thickness_m") - rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None - edges = design.get("road_edges") or {} - road = None - if edges.get("left") and edges.get("right"): - road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"])) - heights = [] - if road and line: - for case in (rep.get("result") or {}).get("cases") or []: - if case.get("condition") != "dry" or not case.get("height_m"): - continue - left = case.get("side") == "left" - edge = road[1] if left else road[0] - day = line[-1][0] if left else line[0][0] - z0, z1 = figure._interp(surface, edge), figure._interp(surface, day) - if z0 is not None and z1 is not None: - heights.append({"x": day, "z0": z0, "z1": z1}) - return figure.section_svg( - title=title, - ground=ground, - surface=surface, - rock=rock, - slips=slips, - road=road, - heights=heights, - wet=any(s["condition"] == "wet" for s in slips), - ) - - def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]: """s6 3D요약 — 구간 하나 = 한 줄(20 행부터) · 평면 위험 띠 그림 일감.""" ground = inputs["ground"] @@ -620,14 +430,22 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st """양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감.""" by = {s["id"]: s for s in book["시트"]} fill_conditions(by["s3"], inputs) + n = len(inputs["result2d"]["stations"]) fill_stations(by["s4"], inputs) - jobs = fill_sections(by["s5"], inputs) + printer.balance_breaks(by["s4"], book, 6, 5 + n) + jobs = section.fill_sections(by["s5"], inputs) + section.fill_slices(by["s10"], inputs) jobs.append(fill_3d(by["s6"], inputs)) area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) + risky = [ + r for r in (inputs["area"] or {}).get("stations") or [] if r.get("level") in ("mid", "high") + ] + printer.balance_breaks(by["s7"], book, 22, 21 + len(risky)) if inputs.get("reinforce"): reinforce.fill_reinforce(by["s8"], inputs["reinforce"], _labels(inputs["stations"])) else: # 보강 검토가 없으면 시트를 뺌 book["시트"] = [s for s in book["시트"] if s["id"] != "s8"] + printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc` return jobs diff --git a/E05_StabReport/E05_StabReport_Cells.py b/E05_StabReport/E05_StabReport_Cells.py new file mode 100644 index 000000000..fedf4c4ec --- /dev/null +++ b/E05_StabReport/E05_StabReport_Cells.py @@ -0,0 +1,98 @@ +"""E05 보고서 칸 쓰기 공용 — 이름표 · 서식 번호 · 칸 값 · 판정 · 기준 안전율 · 측점 이름. + +(PLAN 534-3 · 534-5) 빌더 · 대표 단면(`_Section`) · 인쇄(`_Print`)가 같이 씀. +""" + +from __future__ import annotations + +import re +from typing import Any + +CASES = ("dry", "wet", "seismic") +SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"} +METHOD_NAMES = { + "bishop": "Bishop", + "janbu": "Janbu 보정", + "janbu_corrected": "Janbu 보정", + "ordinary": "Fellenius", + "spencer": "Spencer", +} +REASONS = { + "fill_height": "쌓기 높이 10 m 이상", + "cut_height": "깎기 높이 15 m 이상", + "lowest": "최소 안전율 하위", + "user": "사용자 지정", +} +GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"} +ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17 +A1 = re.compile(r"^([A-Z]+)(\d+)$") + + +def fig_key(station_key: str) -> str: + return f"fig/{station_key}.png" + + +UNCONFIRMED = "기본값 · 사용자 확정 전" + + +def mark(text: str, default: bool) -> str: + """사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 아니면 + 「(사용자 입력)」.""" + return f"{text} ({UNCONFIRMED if default else '사용자 입력'})" + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + """값(None = 지움) · 서식(None = 양식 그대로).""" + cell = dict(sheet["칸"].get(a1) or {}) + cell.pop("식", None) + if value is None or value == "": + cell.pop("값", None) + else: + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + if cell: + sheet["칸"][a1] = cell + else: + sheet["칸"].pop(a1, None) + + +def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None: + for col in cols: + cell = sheet["칸"].get(f"{col}{src}") + if cell and dst != src: + sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {} + + +def _shift(a1: str, dr: int) -> str: + m = A1.match(a1) + return f"{m.group(1)}{int(m.group(2)) + dr}" + + +def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: + if ok is None: + _set(sheet, a1, None) + else: + _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) + + +def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float: + sf = (ground.get("safety_factors") or {}).get(case) or {} + if side in ("cut", "fill"): + return float(sf[side]) + return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽 + + +def _seismic(ground: dict[str, Any]) -> bool: + return bool((ground.get("seismic") or {}).get("enabled")) + + +def _labels(stations: list[dict[str, Any]]) -> dict[float, str]: + return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations} + + +def _label_at(labels: dict[float, str], chainage: float) -> str: + if not labels: + return f"{chainage:.2f}" + near = min(labels, key=lambda c: abs(c - chainage)) + return labels[near] or f"{chainage:.2f}" diff --git a/E05_StabReport/E05_StabReport_Figure.py b/E05_StabReport/E05_StabReport_Figure.py index 69e4f636c..36816d8d6 100644 --- a/E05_StabReport/E05_StabReport_Figure.py +++ b/E05_StabReport/E05_StabReport_Figure.py @@ -1,8 +1,8 @@ """E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦. - section_svg(…) → 대표 단면(712 × 533 · 4 : 3) — 원지반 · 공사 뒤 지표 · 암 경계 · - 우기 지하수위 · - 조건별 임계 활동면 · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금 + section_svg(…) → 대표 단면(712 × 640) — 원지반 · 공사 뒤 지표 · 지층(무늬) · 성토(점) · + 우기 지하수위 · 조건별 임계 활동면(선 모양) · 노폭 · 비탈 높이 치수 · + 표고 · 거리 눈금 · 축척 막대 · 안전율 상자 · 지층 범례 band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠 좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset @@ -16,7 +16,7 @@ import math from html import escape from typing import Any -SECTION_W, SECTION_H = 712, 533 +SECTION_W, SECTION_H = 712, 640 BAND_W, BAND_H = 684, 320 OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222" CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"} @@ -54,6 +54,38 @@ def _interp(line: list[tuple[float, float]], x: float) -> float | None: return None +def _legend_rows(items: list[tuple[str, str]], width: float, size: int) -> list[list[int]]: + """범례 항목 [(종류, 글)] → 줄마다 항목 번호(폭을 넘으면 다음 줄).""" + rows: list[list[int]] = [[]] + used = 0.0 + for i, (_, label) in enumerate(items): + w = 30 + _text_w(label, size) + if rows[-1] and used + w > width: + rows.append([]) + used = 0.0 + rows[-1].append(i) + used += w + return rows + + +def _text_w(text: str, size: int) -> float: + """글 폭 근사(px) — 한글 · 기호 = 글자 크기 · 그 밖 = 0.6 배.""" + return sum(size if ord(ch) > 0x2000 else 0.6 * size for ch in text) + + +PATTERNS = ( + "" + "" + "" + "" + "" + "" +) +STRATA_FILL = {"soil": "#F3E9D8", "rock": "url(#rockHatch)", "fill": "url(#fillDots)"} + + def section_svg( *, title: str, @@ -64,16 +96,20 @@ def section_svg( road: tuple[float, float] | None, heights: list[dict[str, Any]], wet: bool, + strata: list[dict[str, str]] | None = None, + fs_box: list[dict[str, Any]] | None = None, ) -> str: - """대표 단면 SVG. + """대표 단면 SVG(흑백 인쇄에도 읽힘 — 임계면 = 선 모양 · 지층 = 무늬). ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] · - road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1}] 비탈 높이 치수. + road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1, label?}] 비탈 높이 치수 · + strata = [{kind: soil | rock | fill, text}] 지층 범례 · fs_box = [{condition, F, required, ok}] + 안전율 상자 · title = 빈 글이면 안 씀(보고서 쪽 제목이 있음). """ - pad_l, pad_r, pad_t, pad_b = 56, 16, 34, 64 + pad_l, pad_r, pad_t, pad_b = 58, 18, 30 if title else 22, 88 pts = [*ground, *surface, *(p for s in slips for p in s["points"])] if not pts: - return _empty(SECTION_W, SECTION_H, title) + return _empty(SECTION_W, SECTION_H, title or "대표 단면") xs = [p[0] for p in pts] zs = [p[1] for p in pts] x_lo, x_hi = min(xs), max(xs) @@ -94,12 +130,24 @@ def section_svg( def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]: return [(sx(x), sz(z)) for x, z in line] + def area(line: list[tuple[float, float]], y: float) -> str: + """선과 y 사이를 닫은 다각형 점 글.""" + body = screen(line) + return " ".join(f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], y), (body[0][0], y)]) + + bottom, top = sz(z_lo), pad_t out = [ f"", - f"", - _text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK), + f"{PATTERNS}", ] + if surface: + out.append(f"") + if ground: + out.append(f"") + out += ["", f""] + if title: + out.append(_text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK)) # 눈금(표고 · 거리) step_z = _nice_step(z_hi - z_lo, 6) z = math.ceil(z_lo / step_z) * step_z @@ -107,58 +155,57 @@ def section_svg( y = sz(z) out.append( f"" + "stroke='#E6E6E6' stroke-width='1'/>" ) - out.append(_text(pad_l - 6, y + 4, f"{z:g}", 10, "end", fill="#666666")) + out.append(_text(pad_l - 6, y + 4, f"{z:g}", 11, "end", fill="#444444")) z += step_z step_x = _nice_step(x_hi - x_lo, 8) x = math.ceil(x_lo / step_x) * step_x - base_y = SECTION_H - pad_b + 14 + base_y = SECTION_H - pad_b + 15 while x <= x_hi: - out.append(_text(sx(x), base_y, f"{x:g}", 10, fill="#666666")) + out.append(_text(sx(x), base_y, f"{x:g}", 11, fill="#444444")) x += step_x - out.append(_text(pad_l - 6, pad_t - 8, "EL. (m)", 10, "end", fill="#666666")) + out.append(_text(pad_l - 6, pad_t - 8, "EL.(m)", 10, "end", fill="#444444")) out.append( - _text(SECTION_W - pad_r, base_y + 14, "offset (m) · 좌 +", 10, "end", fill="#666666") + _text(SECTION_W - pad_r, base_y + 15, "offset (m) · 좌 +", 10, "end", fill="#444444") ) - # 지반 · 지층 - if ground: - bottom = sz(z_lo) - body = screen(ground) - out.append( - "" + f"" + ) + out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK)) + # 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금) + if surface: + out.append(f"") + if ground: + out.append( + f"" ) - + "' fill='#F3E9D8' stroke='none'/>" - ) if rock: - rb = screen(rock) - out.append( - "" - ) - out.append(_poly(rb, stroke="#795548", stroke_width="1.2", stroke_dasharray="6 3")) - out.append(_poly(screen(ground), stroke="#8D6E63", stroke_width="1.4")) - out.append(_poly(screen(surface), stroke=INK, stroke_width="2")) + clip = " clip-path='url(#below)'" if surface else "" + out.append(f"") + out.append(_poly(screen(rock), stroke="#6D4C41", stroke_width="1", stroke_dasharray="6 3")) + out.append(_poly(screen(ground), stroke="#6D4C41", stroke_width="1.3", stroke_dasharray="3 2")) + out.append(_poly(screen(surface), stroke=INK, stroke_width="2.2")) if wet and surface: # 우기 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3) — ▽ 표시 for frac in (0.2, 0.8): px, pz = surface[int(frac * (len(surface) - 1))] a, b = sx(px), sz(pz) - 4 out.append( - f"" + f"" ) # 임계 활동면 for slip in slips: color = CONDITION_COLORS.get(slip["condition"], NG) dash = SLIP_DASH.get(slip["condition"]) extra = {"stroke_dasharray": dash} if dash else {} - out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2", **extra)) + out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.6", **extra)) # 치수 — 노폭 · 비탈 높이 if road: r_off, l_off = road @@ -168,9 +215,9 @@ def section_svg( a, b = sx(l_off), sx(r_off) out.append( f"" + "stroke-width='0.9' marker-start='url(#t)' marker-end='url(#t)'/>" ) - out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 10, fill=INK)) + out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 11, fill=INK)) for dim in heights: if abs(dim["z1"] - dim["z0"]) < 0.05: continue @@ -178,55 +225,92 @@ def section_svg( y0, y1 = sz(dim["z0"]), sz(dim["z1"]) out.append( f"" + "stroke-width='0.9' marker-start='url(#t)' marker-end='url(#t)'/>" ) - left = a < SECTION_W / 2 # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) - label = f"H {abs(dim['z1'] - dim['z0']):.2f} m" - out.append( - _text( - a - 4 if left else a + 4, - (y0 + y1) / 2, - label, - 10, - "end" if left else "start", - fill=INK, - ) - ) - out.insert( - 2, - "" - "", - ) - # 범례 - legend = [("#8D6E63", "원지반", ""), (INK, "공사 뒤 지표", "")] + label = dim.get("label") or f"H {abs(dim['z1'] - dim['z0']):.2f} m" + # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) · 그림 밖으로 나가면 반대쪽 + left = a < SECTION_W / 2 + if not left and a + 4 + _text_w(label, 11) > SECTION_W - 2: + left = True + if left and a - 4 - _text_w(label, 11) < 2: + left = False + anchor = "end" if left else "start" + out.append(_text(a - 4 if left else a + 4, (y0 + y1) / 2, label, 11, anchor, fill=INK)) + if fs_box: + out.append(_fs_box(fs_box, surface, sx, sz, pad_l, pad_t, SECTION_W - pad_r)) + # 범례 — 선 · 표시 · 지층 무늬(폭을 넘으면 다음 줄) + items: list[tuple[str, str]] = [("#6D4C41|3 2", "원지반"), (f"{INK}|", "공사 뒤 지표")] if rock: - legend.append(("#795548", "암 경계", "6 3")) + items.append(("#6D4C41|6 3", "지층 경계")) for slip in slips: name = CONDITION_NAMES.get(slip["condition"], slip["condition"]) f = slip.get("F") - legend.append( - ( - CONDITION_COLORS.get(slip["condition"], NG), - f"{name} 임계면 Fs {f:.2f}" if f else name, - SLIP_DASH.get(slip["condition"], ""), - ) - ) + color = CONDITION_COLORS.get(slip["condition"], NG) + label = f"{name} 임계면 Fs {f:.2f}" if f else name + items.append((f"{color}|{SLIP_DASH.get(slip['condition'], '')}", label)) if wet: - legend.append(("#1565C0", "▽ 우기 지하수위 = 지표", "")) - lx, ly = pad_l, SECTION_H - 22 - for color, label, dash in legend: - dash_attr = f" stroke-dasharray='{dash}'" if dash else "" - out.append( - f"" - ) - out.append(_text(lx + 22, ly + 4, label, 10, "start", fill=INK)) - lx += 34 + sum(10 if ord(ch) > 0x2000 else 6 for ch in label) # 한글 · 기호 폭 + items.append(("▽", "우기 지하수위 = 지표")) + for layer in strata or []: + items.append((f"@{layer['kind']}", layer["text"])) + ly = SECTION_H - 46 + for row in _legend_rows(items, SECTION_W - pad_l - pad_r, 11): + lx = float(pad_l) + for i in row: + kind, label = items[i] + if kind.startswith("@"): + out.append( + f"" + ) + elif kind == "▽": + out.append( + f"" + ) + else: + color, dash = kind.split("|") + dash_attr = f" stroke-dasharray='{dash}'" if dash else "" + out.append( + f"" + ) + out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK)) + lx += 30 + _text_w(label, 11) + ly += 17 out.append("") return "".join(out) +def _fs_box(rows, surface, sx, sz, left: float, top: float, right: float) -> str: + """안전율 상자 — 하늘 쪽(지표가 낮은 끝) 위 모서리 · 조건 · Fs · 기준 · 판정.""" + width, line = 236, 19 + height = 24 + line * len(rows) + # surface = offset 오름차순 · 화면 왼끝 = 큰 offset = 끝 점 + low_left = bool(surface) and sz(surface[-1][1]) > sz(surface[0][1]) + x = left + 6 if low_left else right - width - 6 + y = top + 6 + out = [ + f"", + f"", + ] + for cx, head, anchor in ((8, "조건", "start"), (78, "Fs", "end"), (132, "기준", "end")): + out.append(_text(x + cx, y + 16, head, 11, anchor, font_weight="bold", fill=INK)) + out.append(_text(x + 196, y + 16, "판정", 11, font_weight="bold", fill=INK)) + for k, r in enumerate(rows): + yy = y + 22 + line * (k + 1) - 5 + name = CONDITION_NAMES.get(r["condition"], r["condition"]) + f = "-" if r.get("F") is None else f"{r['F']:.2f}" + verdict = "-" if r.get("ok") is None else "O.K" if r["ok"] else "N.G" + color = INK if r.get("ok") is None else OK if r["ok"] else NG + out.append(_text(x + 8, yy, name, 11, "start", fill=INK)) + out.append(_text(x + 78, yy, f, 11, "end", font_weight="bold", fill=INK)) + out.append(_text(x + 132, yy, f"≥ {r['required']:.2f}", 11, "end", fill=INK)) + out.append(_text(x + 196, yy, verdict, 11, font_weight="bold", fill=color)) + return "".join(out) + + def band_svg( *, title: str, diff --git a/E05_StabReport/E05_StabReport_Print.py b/E05_StabReport/E05_StabReport_Print.py new file mode 100644 index 000000000..2da6e4f76 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Print.py @@ -0,0 +1,229 @@ +"""E05 보고서 인쇄 — 목차 · 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔(요구 347-5 · 351 · PLAN 534-5). + + fill_toc(sheet, book, pages) 목차 줄(장 · 내용 · 쪽) — 쪽 없으면 「-」 + number_toc(book) 시트별 쪽 수를 재서 목차 쪽 번호를 채움(브라우저 한 번) + report_pdf(book, figures) 표지(쪽 번호 없음) + 본문(머리말 사업명 · 꼬리말 n / N) 한 벌 + balance_breaks(sheet, book, first, last) + 긴 표 쪽 나눔 — 쪽마다 고르게(마지막 쪽에 한두 줄만 남지 않게) + +쪽 번호 = 표지를 뺀 본문 차례(목차 = 1 쪽). 쪽 수 = 시트 하나씩 인쇄 HTML → PDF 쪽 수 +(시트 경계 = 늘 새 쪽 · `spreadsheet_print_html.ts`). 쪽 재기 · PDF = 공용 PDF 엔진(헤드리스). +""" + +from __future__ import annotations + +import io +import math +from html import escape +from typing import Any + +from common_util.common_util_print_pdf import merge_pdfs, render_htmls_to_pdfs +from common_util.common_util_spreadsheet import print_htmls +from E05_StabReport.E05_StabReport_Cells import A1, _set + +COVER, TOC = "s1", "s9" +TOC_FIRST = 4 # 목차 줄 = 4 행부터(4 행 서식) +TITLES = { + "s2": "개요 및 판정 요약", + "s3": "입력 조건 — 해석 방법 · 기준 안전율 · 지하수 · 하중 · 물성치", + "s4": "측점별 안정성 판정 일람표", + "s5": "대표 단면 해석 결과 — 단면도 · 임계 활동면 · 안전율", + "s6": "3D 해석 요약", + "s7": "광역 자연사면 위험 요약", + "s8": "보강 검토 — 비탈 완화 · 소단 · 벽 외적 안정", + "s10": "대표 단면 절편 계산표", +} +APPENDIX = {"s10"} +REPORT_NAME = "비탈면 안정성 검토 보고서" +FONT = "font-family:'Malgun Gothic','맑은 고딕',sans-serif" +PAPER_MM = {"A4": (210, 297), "A3": (297, 420)} +PX_MM = 96 / 25.4 + + +def _row(a1: str) -> int: + return int(A1.match(a1.split(":")[0]).group(2)) + + +def _visible(book: dict[str, Any]) -> list[dict[str, Any]]: + return [s for s in book["시트"] if not s.get("숨김")] + + +def _start_pages(book: dict[str, Any], pages: dict[str, int]) -> dict[str, int]: + """시트 id → 본문 시작 쪽(표지 뺌 · 첫 본문 시트 = 1).""" + out, at = {}, 1 + for s in _visible(book): + if s["id"] == COVER: + continue + out[s["id"]] = at + at += pages.get(s["id"], 0) + return out + + +def fill_toc(sheet: dict[str, Any], book: dict[str, Any], pages: dict[str, int] | None) -> None: + """목차 — 보이는 본문 시트(표지 · 목차 뺌)마다 한 줄 · 장 번호 · 부록 · 시작 쪽.""" + tpl = {c: dict(sheet["칸"].get(f"{c}{TOC_FIRST}") or {}) for c in "ABC"} + for a1 in [a for a in sheet["칸"] if _row(a) >= TOC_FIRST]: + cell = sheet["칸"][a1] + if _row(a1) == TOC_FIRST: # 양식 줄 = 서식만 남김 + cell.pop("값", None) + else: + del sheet["칸"][a1] + height = (sheet.get("행") or {}).get(str(TOC_FIRST)) + start = _start_pages(book, pages) if pages else {} + chapter = 0 + for i, s in enumerate(x for x in _visible(book) if x["id"] not in (COVER, TOC)): + r = TOC_FIRST + i + for c, cell in tpl.items(): + sheet["칸"][f"{c}{r}"] = {k: v for k, v in cell.items() if k != "값"} + if height: + sheet["행"][str(r)] = dict(height) + if s["id"] in APPENDIX: + mark = "부록" + else: + chapter += 1 + mark = f"{chapter}." + _set(sheet, f"A{r}", mark) + _set(sheet, f"B{r}", TITLES.get(s["id"], s["이름"])) + _set(sheet, f"C{r}", start.get(s["id"], "-")) + + +def _only(book: dict[str, Any], keep: set[str], hide: bool = False) -> dict[str, Any]: + """`keep` 시트만 보이는(hide = 참이면 `keep` 만 숨긴) 얕은 사본 · 브라우저 계산값 뺌.""" + sheets = [{**s, "숨김": True} if (s["id"] in keep) == hide else s for s in book["시트"]] + return {**{k: v for k, v in book.items() if k != "계산값"}, "시트": sheets} + + +def _figures(book: dict[str, Any], ids: set[str], figures: dict[str, bytes]) -> dict[str, bytes]: + keys = {f["열쇠"] for s in book["시트"] if s["id"] in ids for f in s.get("그림") or []} + return {k: v for k, v in figures.items() if k in keys} + + +def sheet_pages(book: dict[str, Any]) -> dict[str, int]: + """보이는 시트마다 인쇄 쪽 수(그림 없이 — 그림 칸 크기는 행열이 정함).""" + from pypdf import PdfReader + + ids = [s["id"] for s in _visible(book)] + htmls = print_htmls([{"book": _only(book, {sid})} for sid in ids]) + pdfs = render_htmls_to_pdfs([{"html": h, "page_size": None} for h in htmls]) + return {sid: len(PdfReader(io.BytesIO(pdf)).pages) for sid, pdf in zip(ids, pdfs)} + + +def number_toc(book: dict[str, Any]) -> dict[str, int]: + """목차 시트가 있으면 쪽 수를 재서 쪽 번호를 채움(책을 고침) · 시트별 쪽 수.""" + pages = sheet_pages(book) + toc = next((s for s in book["시트"] if s["id"] == TOC and not s.get("숨김")), None) + if toc is not None: + fill_toc(toc, book, pages) + return pages + + +def _header(project: str) -> str: + return ( + f'
{escape(project)}{REPORT_NAME}
' + ) + + +FOOTER = ( + f'
' + '- / -
' +) + + +def report_pdf(book: dict[str, Any], figures: dict[str, bytes]) -> bytes: + """보고서 PDF — 목차 쪽 번호 = 실제 쪽 · 표지 = 쪽 번호 · 머리말 없음.""" + clean = _only(book, set(), hide=True) + clean["시트"] = [ + {**s, "칸": {k: dict(v) for k, v in s["칸"].items()}, "행": dict(s.get("행") or {})} + if s["id"] == TOC + else s + for s in clean["시트"] + ] # 목차만 고칠 사본 + number_toc(clean) + ids = {s["id"] for s in _visible(clean)} + cover = next((s for s in clean["시트"] if s["id"] == COVER), None) + project = str(((cover or {}).get("칸", {}).get("C8") or {}).get("값") or "") + asks, jobs = [], [] + if COVER in ids: + asks.append({"book": _only(clean, {COVER}), "figures": _figures(clean, {COVER}, figures)}) + jobs.append({"page_size": None}) + if ids - {COVER}: + asks.append({"book": _only(clean, {COVER}, hide=True), "figures": figures}) + jobs.append( + {"page_size": None, "header_template": _header(project), "footer_template": FOOTER} + ) + htmls = print_htmls(asks) + pdfs = render_htmls_to_pdfs([{**job, "html": h} for job, h in zip(jobs, htmls)]) + return merge_pdfs(pdfs) if len(pdfs) > 1 else pdfs[0] + + +# ── 긴 표 쪽 나눔 ──────────────────────────────────────────────────────────── +def _col_px(chars: float) -> int: + """`spreadsheet_headers.colCharsToPx` 와 같은 꼴.""" + return round(min(chars, 255) * 7 + 5) + + +def _page_fit(sheet: dict[str, Any], book: dict[str, Any]) -> tuple[float, Any]: + """(쪽 하나 높이 px · 배율 반영) · 행 높이 px 함수 — 인쇄 HTML 과 같은 셈.""" + pr = sheet.get("인쇄") or {} + w, h = PAPER_MM[pr.get("용지") or "A4"] + if pr.get("방향") == "landscape": + w, h = h, w + m = {"위": 10, "아래": 10, "왼": 10, "오른": 10, **(pr.get("여백") or {})} + last_col = max( + (sum((ord(ch) - 64) * 26**i for i, ch in enumerate(reversed(A1.match(a).group(1)))) + for a in sheet["칸"]), + default=1, + ) # fmt: skip + cols = [_col_name(c) for c in range(1, last_col + 1)] + default_w = ( + (sheet.get("기본") or {}).get("열폭") or (book.get("기본") or {}).get("열폭") or 8.43 + ) + table = sum(_col_px((sheet.get("열") or {}).get(c, {}).get("폭") or default_w) for c in cols) + if (pr.get("맞춤") or {}).get("폭"): + zoom = min(1.0, (w - m["왼"] - m["오른"]) * PX_MM / max(table, 1)) + else: + zoom = (pr.get("배율") or 100) / 100 + default_pt = (book.get("기본") or {}).get("행높이") or 15 + + def row_px(r: int) -> float: + return ((sheet.get("행") or {}).get(str(r), {}).get("높이") or default_pt) * 4 / 3 + + return (h - m["위"] - m["아래"]) * PX_MM / zoom, row_px + + +def _col_name(n: int) -> str: + out = "" + while n: + n, k = divmod(n - 1, 26) + out = chr(65 + k) + out + return out + + +def balance_breaks(sheet: dict[str, Any], book: dict[str, Any], first: int, last: int) -> None: + """`first`~`last` 행 표를 쪽마다 고르게 나눔(쪽나눔 = 덩이 끝 행) — 한 쪽이면 그대로. + + ponytail: 한 줄 글 행만 맞음(줄바꿈으로 키 큰 행은 셈 밖) · 어긋남 대비 한 줄 덜 담음. + """ + if last < first: + return + page, row_px = _page_fit(sheet, book) + head = (sheet.get("인쇄") or {}).get("머리행") + head_px = sum(row_px(r) for r in range(head[0], head[1] + 1)) if head else 0.0 + pre_px = sum(row_px(r) for r in range(1, head[0] if head else first)) + rows = list(range(first, last + 1)) + cap_first = max(1, math.floor((page - pre_px - head_px) / row_px(first)) - 1) + cap_next = max(1, math.floor((page - head_px) / row_px(first)) - 1) + caps = [cap_first] + while sum(caps) < len(rows): # 쪽 수 + caps.append(cap_next) + if len(caps) == 1: + return + breaks, at, left = [], 0, len(rows) + for i, cap in enumerate(caps[:-1]): + take = min(cap, math.ceil(left / (len(caps) - i))) + at += take + left -= take + breaks.append(rows[at - 1]) + sheet["인쇄"]["쪽나눔"] = breaks diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py index b27f334db..28961e1ec 100644 --- a/E05_StabReport/E05_StabReport_Router.py +++ b/E05_StabReport/E05_StabReport_Router.py @@ -29,11 +29,12 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from common_util.common_util_print_pdf import PrintError -from common_util.common_util_spreadsheet import RecalcError, with_server_values, workbook_pdf +from common_util.common_util_spreadsheet import RecalcError, with_server_values from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workbook_xlsx import workbook_to_xlsx from config.config_db import get_db_pool from E05_StabReport import E05_StabReport_Build as build +from E05_StabReport import E05_StabReport_Print as printer from E05_StabReport import E05_StabReport_Store as store router = APIRouter(prefix="/api/projects", tags=["E05 Stab Report"]) @@ -192,9 +193,7 @@ def _export(root: Path, book: dict[str, Any] | None, kind: str) -> bytes: figures = store.figures_of(root, book) if kind == "xlsx": return workbook_to_xlsx(with_server_values(book), figures) - clean = dict(book) - clean.pop("계산값", None) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀) - return workbook_pdf(clean, figures) + return printer.report_pdf(book, figures) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀) async def _export_response(project_id: UUID, book: dict[str, Any] | None, kind: str) -> Response: diff --git a/E05_StabReport/E05_StabReport_Section.py b/E05_StabReport/E05_StabReport_Section.py new file mode 100644 index 000000000..37ef40bf6 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Section.py @@ -0,0 +1,318 @@ +"""E05 보고서 s5 대표단면 · s10 부록 절편 — 단면 하나 = 한 쪽(PLAN 534-3 · 534-5). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s5 · s10. +그림 = `E05_StabReport_Figure.section_svg`(값 셈 없음 · 해석 저장본 옮겨 그림). +""" + +from __future__ import annotations + +import copy +import math +from typing import Any + +from E03_StabSection import E03_StabSection_View as view2d +from E03_StabSection.E03_StabSection_Engine_Model import final_surface +from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport.E05_StabReport_Cells import ( + A1, + CASES, + GROUND_NAMES, + METHOD_NAMES, + REASONS, + SIDE_NAMES, + _criterion, + _label_at, + _labels, + _seismic, + _set, + _shift, + _verdict, + fig_key, +) + +BLOCK = 28 # 대표단면 한 쪽 = 28 행 +LOWEST_N = view2d.REPRESENTATIVE_LOWEST +CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SLICE_COLS = "ABCDEFGHIJKLM" +SLICE_TPL = 5 # 부록 절편 덩이 꼴 = 양식 1~5 행(제목 · 단위 · 머리 · 줄 · 합계) + + +def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str): + """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이.""" + best = None + for case in (result or {}).get("cases") or []: + if case.get("condition") != condition: + continue + if side in ("cut", "fill") and case.get("role") not in (side, None): + continue + methods = (case.get("result") or {}).get("methods") or {} + found = (methods.get(method) or {}).get("min") or next( + (v["min"] for v in methods.values() if v.get("min")), None + ) + if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]): + best = (found, case) + return best + + +def _block_copy( + sheet: dict[str, Any], template: dict[str, Any], dr: int, rows: int, tag: str +) -> None: + """양식 1~rows 행 덩이를 dr 행 아래로(칸 · 병합 · 행 높이 · 조건부서식).""" + for a1, cell in template["칸"].items(): + if int(A1.match(a1).group(2)) <= rows: + sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell) + for m in template.get("병합", []): + if int(A1.match(m.split(":")[0]).group(2)) <= rows: + sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":"))) + for r, info in (template.get("행") or {}).items(): + if int(r) <= rows: + sheet["행"][str(int(r) + dr)] = dict(info) + for rule in template.get("조건부서식", []): + moved = copy.deepcopy(rule) + moved["id"] = f"{rule['id']}_{tag}" + moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]] + sheet["조건부서식"].append(moved) + + +def _clear(sheet: dict[str, Any]) -> dict[str, Any]: + """시트를 비우고 양식 사본을 돌려줌.""" + template = copy.deepcopy(sheet) + sheet["칸"] = {} + sheet["병합"], sheet["행"], sheet["조건부서식"] = [], {}, [] + if "그림" in sheet: + sheet["그림"] = [] + return template + + +def _mark(k: int) -> str: + return CIRCLED[k] if k < len(CIRCLED) else str(k + 1) + + +def _picks(rep: dict[str, Any], ground: dict[str, Any]) -> dict[str, Any]: + """대표 단면 하나 — 조건별 (면, 경우) · 쓰는 조건 · 지배 조건(Fs / 기준 가장 낮음).""" + st = rep["summary"] + side, method = st.get("side"), st.get("method") or "bishop" + used = [c for c in CASES if c != "seismic" or _seismic(ground)] + picks = {c: _case_slip(rep.get("result"), c, side, method) for c in used} + fs = st.get("fs_by_condition") or {} + ratios = [ + (float(fs[c]) / _criterion(ground, c, side), c) + for c in used + if fs.get(c) is not None and picks.get(c) + ] + governing = min(ratios)[1] if ratios else None + return {"side": side, "method": method, "used": used, "picks": picks, "governing": governing} + + +def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]: + """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)].""" + reps = inputs["representatives"] + ground = inputs["ground"] + labels = _labels(inputs["stations"]) + template = _clear(sheet) + if not reps: + _block_copy(sheet, template, 0, BLOCK, "0") + _set(sheet, "A1", "대표 단면 — 해당 없음") + sheet["인쇄"]["쪽나눔"] = [] + return [] + jobs: list[tuple[str, str]] = [] + for k, rep in enumerate(reps): + dr = BLOCK * k + _block_copy(sheet, template, dr, BLOCK, str(k)) + st = rep["summary"] + got = _picks(rep, ground) + side, method, picks = got["side"], got["method"], got["picks"] + label = _label_at(labels, float(st["chainage_m"])) + dry = picks.get("dry") + height = float(dry[1].get("height_m") or 0) if dry else 0.0 + _set( + sheet, + f"A{1 + dr}", + f"대표 단면 {_mark(k)} — {label} · {SIDE_NAMES.get(side, side or '-')} · " + f"H {height:.1f} m", + ) + reason = REASONS.get(rep.get("reason"), rep.get("reason") or "") + if rep.get("reason") == "lowest": + reason = f"{reason} {LOWEST_N}" + _set(sheet, f"B{2 + dr}", reason) + key = fig_key(rep["station_key"]) + sheet["그림"].append( + {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"} + ) + fs = st.get("fs_by_condition") or {} + slips, box = [], [] + for r, case in zip((24, 25, 26), CASES): + row = r + dr + if case not in got["used"]: + _set(sheet, f"B{row}", "검토 안 함") + continue + value = fs.get(case) + crit = _criterion(ground, case, side) + ok = None if value is None else float(value) >= crit - 1e-9 + pick = picks.get(case) + _set(sheet, f"B{row}", METHOD_NAMES.get(method, method)) + _set(sheet, f"C{row}", float(value) if value is not None else None) + _set(sheet, f"D{row}", crit) + _verdict(sheet, f"E{row}", ok) + slip = pick[0] if pick else None + center = (slip or {}).get("center") + circle = bool(slip and slip.get("radius") and center) + _set(sheet, f"F{row}", float(slip["radius"]) if circle else "-" if slip else None) + _set(sheet, f"G{row}", float(center[0]) if circle else "-" if slip else None) + _set(sheet, f"H{row}", float(center[1]) if circle else "-" if slip else None) + if slip and slip.get("points"): + slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]}) + box.append({"condition": case, "F": value, "required": crit, "ok": ok}) + design = (rep.get("cross") or {}).get("design") or {} + kind = (dry[0].get("kind") if dry else None) or "" + layers = rep.get("layers") or {} + note = [ + {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""), + f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}", + f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "", + f"부록 {_mark(k)} 절편 계산표" if got["governing"] else "", + ] + _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n)) + jobs.append((key, _section_figure(rep, slips, box, ground))) + sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))] + return jobs + + +def _strata(rep: dict[str, Any], ground: dict[str, Any], fill: bool) -> list[dict[str, str]]: + """지층 범례 — 성토(공사 뒤 지표가 원지반 위인 곳이 있으면) · 위 지층 · 아래 지층.""" + names = {m["id"]: m for m in ground.get("materials") or []} + layers = rep.get("layers") or {} + out = [] + picks = [ + ("fill", (ground.get("label_map") or {}).get("fill") or "fill", fill), + ("soil", layers.get("upper"), True), + ("rock", layers.get("lower"), bool(layers.get("soil_thickness_m"))), + ] + for kind, mid, show in picks: + m = names.get(mid) + if show and m: + text = f"{m['name']} γt {m['gamma_t']:g} · c {m['c']:g} · φ {m['phi']:g}" + out.append({"kind": kind, "text": text}) + return out + + +def _section_figure( + rep: dict[str, Any], slips: list[dict[str, Any]], box: list[dict[str, Any]], soil: dict +) -> str: + cross = rep.get("cross") or {} + design = cross.get("design") or {} + ground = sorted( # 해석에 쓴 넓힌 지반선(임계면이 횡단 표본 밖까지 감) · 없으면 횡단 표본 + (float(x), float(z)) for x, z in rep.get("ground") or [] + ) or sorted( + (float(s["offset_m"]), float(s["z"])) + for s in cross.get("samples") or [] + if s.get("valid", True) and s.get("z") is not None + ) + line = sorted( + (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] + ) + surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground + thickness = (rep.get("layers") or {}).get("soil_thickness_m") + rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None + edges = design.get("road_edges") or {} + road = None + if edges.get("left") and edges.get("right"): + road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"])) + heights = [] + if road and line: + for case in (rep.get("result") or {}).get("cases") or []: + if case.get("condition") != "dry" or not case.get("height_m"): + continue + left = case.get("side") == "left" + edge = road[1] if left else road[0] + day = line[-1][0] if left else line[0][0] + z_edge, z1 = figure._interp(surface, edge), figure._interp(surface, day) + if z_edge is not None and z1 is not None: # 높이 = 엔진 값(비탈 끝에서 길 쪽으로) + h = float(case["height_m"]) + z0 = z1 + h if z_edge > z1 else z1 - h + heights.append({"x": day, "z0": z0, "z1": z1, "label": f"H {h:.2f} m"}) + filled = any( + (z_g := figure._interp(ground, x)) is not None and z - z_g > 0.05 for x, z in surface + ) + return figure.section_svg( + title="", + ground=ground, + surface=surface, + rock=rock, + slips=slips, + road=road, + heights=heights, + wet=any(s["condition"] == "wet" for s in slips), + strata=_strata(rep, soil, filled), + fs_box=box, + ) + + +# ── s10 부록 절편 ──────────────────────────────────────────────────────────── +def fill_slices(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: + """s10 부록 절편 — 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면 절편표 한 쪽.""" + ground = inputs["ground"] + labels = _labels(inputs["stations"]) + template = _clear(sheet) + data_tpl = {c: dict(template["칸"].get(f"{c}4") or {}) for c in SLICE_COLS} + sum_tpl = {c: dict(template["칸"].get(f"{c}5") or {}) for c in SLICE_COLS} + data_h = (template.get("행") or {}).get("4") + sum_h = (template.get("행") or {}).get("5") + dr, breaks = 0, [] + for k, rep in enumerate(inputs["representatives"]): + got = _picks(rep, ground) + cond = got["governing"] + slip = got["picks"][cond][0] if cond else None + rows = (slip or {}).get("slices") or [] + if not rows: + continue + _block_copy(sheet, template, dr, 3, str(k)) + label = _label_at(labels, float(rep["summary"]["chainage_m"])) + F = float(slip["F"]) + _set( + sheet, + f"A{1 + dr}", + f"부록 {_mark(k)} 절편 계산표 — {label} · {SIDE_NAMES.get(got['side'], '-')} · " + f"{CONDITION_NAMES[cond]} · {METHOD_NAMES.get(got['method'], got['method'])} · " + f"Fs {F:.3f}", + ) + kind = {"circle": "원호", "polyline": "비원호"}.get(slip.get("kind"), "") + _set( + sheet, + f"A{2 + dr}", + f"대표 단면 {_mark(k)} 의 {CONDITION_NAMES[cond]} 임계 활동면({kind}) · " + f"{template['칸']['A2']['값']}", + ) + sums = [0.0, 0.0, 0.0, 0.0] + for i, s in enumerate(rows): + r = 4 + dr + i + for c, cell in data_tpl.items(): + sheet["칸"][f"{c}{r}"] = dict(cell) + if data_h: + sheet["행"][str(r)] = dict(data_h) + drive = (s["weight"] + s["surcharge"]) * math.sin(math.radians(s["alpha_deg"])) + resist = s["shear_mobilized"] * F + values = ( + i + 1, s["width"], s["height"], s["alpha_deg"], s["weight"], s["surcharge"], + s["pore_pressure"], s["base_length"], s["c"], s["phi_deg"], s["normal_eff"], + drive, resist, + ) # fmt: skip + for c, v in zip(SLICE_COLS, values): + _set(sheet, f"{c}{r}", float(v) if c != "A" else v) + for j, v in enumerate((s["weight"], s["surcharge"], drive, resist)): + sums[j] += v + r = 4 + dr + len(rows) + for c, cell in sum_tpl.items(): + sheet["칸"][f"{c}{r}"] = dict(cell) + if sum_h: + sheet["행"][str(r)] = dict(sum_h) + _set(sheet, f"A{r}", "합계") + for c, v in zip("EFLM", sums): + _set(sheet, f"{c}{r}", v) + dr = r + breaks.append(r) + if not breaks: + _block_copy(sheet, template, 0, 2, "0") + _set(sheet, "A1", "부록 — 절편 계산표 없음(대표 단면 없음)") + sheet["인쇄"]["쪽나눔"] = breaks diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py index 8579210ed..d234d8959 100644 --- a/E05_StabReport/E05_StabReport_Store.py +++ b/E05_StabReport/E05_StabReport_Store.py @@ -19,8 +19,10 @@ from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from common_util.common_util_print_pdf import PrintError from common_util.common_util_spreadsheet import with_server_values from common_util.common_util_svg_png import svgs_to_pngs +from E05_StabReport import E05_StabReport_Print as printer from E05_StabReport.E05_StabReport_Build import ( META, PROGRAM_SHEETS, @@ -64,6 +66,15 @@ def _write(project_root: Path, book: dict[str, Any], meta: dict[str, Any]) -> No atomic_write_json(folder / META, meta) +def _numbered(book: dict[str, Any]) -> dict[str, Any]: + """목차 쪽 번호를 실제 쪽 수로(브라우저 없으면 「-」 그대로 · PDF 내보내기 때 다시 셈).""" + try: + printer.number_toc(book) + except PrintError: + pass + return book + + def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]: return { "fingerprint": inputs["fingerprint"], @@ -79,7 +90,7 @@ def create(project_root: Path) -> dict[str, Any]: book = form() fill_cover(next(s for s in book["시트"] if s["id"] == "s1"), inputs) jobs = build_program(book, inputs) - book = with_server_values(book) + book = _numbered(with_server_values(book)) folder = stage_dir(project_root) figures = _write_figures(folder, jobs) meta = _meta(book, inputs, figures) @@ -94,6 +105,22 @@ def create(project_root: Path) -> dict[str, Any]: return meta +def _merge_styles(have: list[dict], form_styles: list[dict], sheets: Any) -> list[dict]: + """새 판 시트의 서식 번호(양식 기준)를 작업본 서식 표 번호로 — 없는 서식은 끝에 붙임 + (옛 작업본 · 사용자가 더한 서식과 번호가 엇갈리지 않게).""" + styles = list(have) + remap: dict[int, int] = {} + for i, style in enumerate(form_styles): + if style not in styles: + styles.append(style) + remap[i] = styles.index(style) + for sheet in sheets: + for cell in sheet["칸"].values(): + if "서식" in cell: + cell["서식"] = remap.get(cell["서식"], cell["서식"]) + return styles + + def refresh(project_root: Path) -> dict[str, Any]: """[보고서 갱신] — 프로그램 몫 시트 · 그림만 다시. 사용자 몫 · 사용자가 더한 시트는 그대로. 덮인 시트에 사용자 고침이 있었으면 `overwritten`(시트 이름) · 옛 판 `_지난/`.""" @@ -104,6 +131,7 @@ def refresh(project_root: Path) -> dict[str, Any]: fresh = form() jobs = build_program(fresh, inputs) built = {s["id"]: s for s in fresh["시트"] if s["id"] in PROGRAM_SHEETS} + book = {**book, "서식": _merge_styles(book.get("서식") or [{}], fresh["서식"], built.values())} signs = (meta or {}).get("signs") or {} overwritten = [ s["이름"] @@ -123,10 +151,13 @@ def refresh(project_root: Path) -> dict[str, Any]: for s in book["시트"] if s["id"] in built or s["id"] not in PROGRAM_SHEETS ] - sheets += [ - built[sid] for sid in PROGRAM_SHEETS if sid in built and sid not in have - ] # 지운 시트는 끝에 다시 - book = with_server_values({**book, "시트": sheets}) + order = [s["id"] for s in fresh["시트"]] + for sid in order: # 없는 프로그램 몫(지움 · 새로 생긴 목차 · 부록) = 양식 차례 자리에 + if sid in built and sid not in have: + before = {x for x in order[: order.index(sid)]} + at = max((i + 1 for i, x in enumerate(sheets) if x["id"] in before), default=0) + sheets.insert(at, built[sid]) + book = _numbered(with_server_values({**book, "시트": sheets})) figures = _write_figures(folder, jobs) new_meta = _meta(book, inputs, figures) _write(project_root, book, new_meta) diff --git a/common_util/common_util_print_pdf.py b/common_util/common_util_print_pdf.py index af885cb90..0961e2a5f 100644 --- a/common_util/common_util_print_pdf.py +++ b/common_util/common_util_print_pdf.py @@ -58,6 +58,12 @@ def _pdf_options(job: dict[str, Any]) -> dict[str, Any]: options.update( display_header_footer=True, header_template="", footer_template=FOOTER ) + if job.get("header_template") or job.get("footer_template"): # 머리말 · 꼬리말 HTML(E05) + options.update( + display_header_footer=True, + header_template=job.get("header_template") or "", + footer_template=job.get("footer_template") or "", + ) return options @@ -102,7 +108,9 @@ def render_htmls_to_pdfs(jobs: list[dict[str, Any]], timeout_s: float = TIMEOUT_ """HTML 여러 벌 → PDF 여러 벌(차례 그대로 · 자식 프로세스 한 번). job = {html, page_size="A4", landscape=False, margins=None, page_numbers=False, - prepare_js=None, prepare_arg=None} — prepare_js = PDF 직전 page.evaluate(줄임 등). + prepare_js=None, prepare_arg=None, header_template=None, footer_template=None} — + prepare_js = PDF 직전 page.evaluate(줄임 등) · header/footer_template = 쪽마다 머리말 · 꼬리말 + HTML(Playwright `pageNumber` · `totalPages` 칸 · 주면 page_numbers 대신). """ jobs = [{"page_size": "A4", **job} for job in jobs] with _lock, tempfile.TemporaryDirectory() as tmp: diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 585ab1a29..8ee581788 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -293,6 +293,156 @@ "선": "thin" } } + }, + { + "크기": 8, + "굵게": true, + "채움": "#DDEBF7", + "가로": "center", + "세로": "center", + "줄바꿈": true, + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "크기": 8, + "가로": "center", + "세로": "center", + "형식": "0.00", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "크기": 8, + "가로": "center", + "세로": "center", + "형식": "0.0", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "크기": 8, + "가로": "center", + "세로": "center", + "형식": "0", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "크기": 11, + "가로": "left", + "세로": "center", + "테두리": { + "아래": { + "선": "hair" + } + } + }, + { + "크기": 11, + "가로": "right", + "세로": "center", + "형식": "0", + "테두리": { + "아래": { + "선": "hair" + } + } + }, + { + "크기": 11, + "굵게": true, + "가로": "left", + "세로": "center", + "테두리": { + "아래": { + "선": "hair" + } + } + }, + { + "크기": 8, + "굵게": true, + "채움": "#F2F2F2", + "가로": "center", + "세로": "center", + "형식": "0.00", + "테두리": { + "위": { + "선": "thin" + }, + "아래": { + "선": "thin" + }, + "왼": { + "선": "thin" + }, + "오른": { + "선": "thin" + } + } + }, + { + "크기": 22, + "굵게": true, + "가로": "center", + "세로": "center", + "테두리": { + "위": { + "선": "thick" + }, + "아래": { + "선": "thick" + } + } } ], "시트": [ @@ -302,7 +452,7 @@ "칸": { "B4": { "값": "비탈면 안정성 검토 보고서", - "서식": 1 + "서식": 26 }, "C4": { "서식": 1 @@ -416,6 +566,10 @@ }, "E15": { "서식": 2 + }, + "B18": { + "값": "검토 기준 — 건설공사 비탈면 설계기준(KDS 11 70 05) · 한계평형해석(Bishop 간편법)", + "서식": 2 } }, "병합": [ @@ -427,7 +581,8 @@ "C11:E11", "C12:E12", "B14:E14", - "B15:E15" + "B15:E15", + "B18:E18" ], "열": { "A": { @@ -451,7 +606,7 @@ }, "행": { "4": { - "높이": 60 + "높이": 72 }, "5": { "높이": 24 @@ -476,6 +631,9 @@ }, "12": { "높이": 30 + }, + "18": { + "높이": 24 } }, "보기": { @@ -496,6 +654,82 @@ } } }, + { + "id": "s9", + "이름": "목차", + "칸": { + "A1": { + "값": "목 차", + "서식": 1 + }, + "A3": { + "값": "장", + "서식": 6 + }, + "B3": { + "값": "내용", + "서식": 6 + }, + "C3": { + "값": "쪽", + "서식": 6 + }, + "A4": { + "서식": 24 + }, + "B4": { + "서식": 22 + }, + "C4": { + "서식": 23 + } + }, + "병합": [ + "A1:C1" + ], + "열": { + "A": { + "폭": 10 + }, + "B": { + "폭": 64 + }, + "C": { + "폭": 10 + } + }, + "행": { + "1": { + "높이": 48 + }, + "2": { + "높이": 12 + }, + "3": { + "높이": 24 + }, + "4": { + "높이": 26 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 30, + "아래": 15, + "왼": 20, + "오른": 20 + } + } + }, { "id": "s2", "이름": "개요", @@ -1566,6 +1800,9 @@ }, "G26": { "서식": 5 + }, + "D22": { + "서식": 5 } }, "병합": [ @@ -1586,7 +1823,8 @@ "B18:G18", "B19:G19", "A21:G21", - "A24:G24" + "A24:G24", + "D22:G22" ], "열": { "A": { @@ -1679,7 +1917,7 @@ "이름": "측점표", "칸": { "A1": { - "값": "측점별 안전율", + "값": "측점별 안정성 판정 일람표", "서식": 1 }, "B1": { @@ -1710,7 +1948,7 @@ "서식": 1 }, "A2": { - "값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G", + "값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G · N.G 측점 = 대책 검토 필요", "서식": 2 }, "B2": { @@ -1841,21 +2079,32 @@ }, "J6": { "서식": 7 + }, + "K4": { + "값": "대책 필요", + "서식": 6 + }, + "K5": { + "서식": 6 + }, + "K6": { + "서식": 7 } }, "병합": [ - "A1:J1", - "A2:J2", + "A1:K1", + "A2:K2", "A4:A5", "B4:B5", "C4:C5", "D4:F4", "G4:I4", - "J4:J5" + "J4:J5", + "K4:K5" ], "열": { "A": { - "폭": 16 + "폭": 20 }, "B": { "폭": 13 @@ -1883,6 +2132,9 @@ }, "J": { "폭": 9 + }, + "K": { + "폭": 12 } }, "행": { @@ -2175,64 +2427,64 @@ "높이": 22 }, "3": { - "높이": 20 + "높이": 24 }, "4": { - "높이": 20 + "높이": 24 }, "5": { - "높이": 20 + "높이": 24 }, "6": { - "높이": 20 + "높이": 24 }, "7": { - "높이": 20 + "높이": 24 }, "8": { - "높이": 20 + "높이": 24 }, "9": { - "높이": 20 + "높이": 24 }, "10": { - "높이": 20 + "높이": 24 }, "11": { - "높이": 20 + "높이": 24 }, "12": { - "높이": 20 + "높이": 24 }, "13": { - "높이": 20 + "높이": 24 }, "14": { - "높이": 20 + "높이": 24 }, "15": { - "높이": 20 + "높이": 24 }, "16": { - "높이": 20 + "높이": 24 }, "17": { - "높이": 20 + "높이": 24 }, "18": { - "높이": 20 + "높이": 24 }, "19": { - "높이": 20 + "높이": 24 }, "20": { - "높이": 20 + "높이": 24 }, "21": { - "높이": 20 + "높이": 24 }, "22": { - "높이": 20 + "높이": 24 }, "23": { "높이": 32 @@ -2998,6 +3250,13 @@ }, "G22": { "서식": 16 + }, + "A9": { + "값": "기본값", + "서식": 4 + }, + "B9": { + "서식": 5 } }, "병합": [ @@ -3008,6 +3267,7 @@ "B6:H6", "B7:H7", "B8:H8", + "B9:H9", "A10:H10", "A20:H20" ], @@ -3055,6 +3315,9 @@ }, "21": { "높이": 32 + }, + "9": { + "높이": 32 } }, "보기": { @@ -3423,6 +3686,233 @@ "오른": 10 } } + }, + { + "id": "s10", + "이름": "부록_절편", + "칸": { + "A1": { + "값": "부록 ① 절편 계산표", + "서식": 3 + }, + "A2": { + "값": "단위 — 길이 m · 힘 kN/m(폭 1 m) · 압력 · 점착력 kPa · 각 °", + "서식": 2 + }, + "A3": { + "값": "번호", + "서식": 18 + }, + "A4": { + "서식": 21 + }, + "A5": { + "값": "합계", + "서식": 18 + }, + "B3": { + "값": "폭 b (m)", + "서식": 18 + }, + "B4": { + "서식": 19 + }, + "B5": { + "서식": 25 + }, + "C3": { + "값": "높이 h (m)", + "서식": 18 + }, + "C4": { + "서식": 19 + }, + "C5": { + "서식": 25 + }, + "D3": { + "값": "저면 경사 α (°)", + "서식": 18 + }, + "D4": { + "서식": 20 + }, + "D5": { + "서식": 25 + }, + "E3": { + "값": "무게 W (kN/m)", + "서식": 18 + }, + "E4": { + "서식": 19 + }, + "E5": { + "서식": 25 + }, + "F3": { + "값": "상재 Q (kN/m)", + "서식": 18 + }, + "F4": { + "서식": 19 + }, + "F5": { + "서식": 25 + }, + "G3": { + "값": "간극수압 u (kPa)", + "서식": 18 + }, + "G4": { + "서식": 19 + }, + "G5": { + "서식": 25 + }, + "H3": { + "값": "저면 길이 l (m)", + "서식": 18 + }, + "H4": { + "서식": 19 + }, + "H5": { + "서식": 25 + }, + "I3": { + "값": "점착력 c (kPa)", + "서식": 18 + }, + "I4": { + "서식": 19 + }, + "I5": { + "서식": 25 + }, + "J3": { + "값": "마찰각 φ (°)", + "서식": 18 + }, + "J4": { + "서식": 20 + }, + "J5": { + "서식": 25 + }, + "K3": { + "값": "유효 수직력 N' (kN/m)", + "서식": 18 + }, + "K4": { + "서식": 19 + }, + "K5": { + "서식": 25 + }, + "L3": { + "값": "구동력 (W+Q)·sinα (kN/m)", + "서식": 18 + }, + "L4": { + "서식": 19 + }, + "L5": { + "서식": 25 + }, + "M3": { + "값": "저항력 S (kN/m)", + "서식": 18 + }, + "M4": { + "서식": 19 + }, + "M5": { + "서식": 25 + } + }, + "병합": [ + "A1:M1", + "A2:M2" + ], + "열": { + "A": { + "폭": 5 + }, + "B": { + "폭": 7.6 + }, + "C": { + "폭": 7.6 + }, + "D": { + "폭": 7.6 + }, + "E": { + "폭": 7.6 + }, + "F": { + "폭": 7.6 + }, + "G": { + "폭": 7.6 + }, + "H": { + "폭": 7.6 + }, + "I": { + "폭": 7.6 + }, + "J": { + "폭": 7.6 + }, + "K": { + "폭": 7.6 + }, + "L": { + "폭": 7.6 + }, + "M": { + "폭": 7.6 + } + }, + "행": { + "1": { + "높이": 24 + }, + "2": { + "높이": 16 + }, + "3": { + "높이": 34 + }, + "4": { + "높이": 12 + }, + "5": { + "높이": 14 + } + }, + "보기": { + "눈금선": false + }, + "인쇄": { + "용지": "A4", + "방향": "portrait", + "맞춤": { + "폭": 1, + "높이": 0 + }, + "여백": { + "위": 15, + "아래": 15, + "왼": 10, + "오른": 10 + }, + "쪽나눔": [ + 5 + ] + } } ], "활성": "s1" diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index b2a94f85f..9a9aadc3c 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -4,8 +4,11 @@ ## 공통 -- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(입력조건 · 측점표 · 대표단면 · 3D요약) = 갱신 때 통째 다시 씀. -- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00). +- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(목차 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토 · 부록_절편) = 갱신 때 통째 다시 씀 · 작업본에 없는 프로그램 몫 시트는 양식 차례 자리에 끼움 · 새 서식은 작업본 서식 표 끝에 붙여 번호를 바꿈. +- 시트 차례: 표지 · 목차 · 개요 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · (보강검토) · 부록_절편. +- PDF: 표지 = 쪽 번호 · 머리말 없음 · 본문 = 머리말(왼 사업명 = 표지 `C8` · 오른 보고서 이름) · 꼬리말 「- n / N -」(목차 = 1 쪽). +- 사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 바꿈 = 「(사용자 입력)」 · 물성 = 「문헌 추정치 · 사용자 확정 전」. +- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00) · 18 head8 · 19 num8(0.00) · 20 num8(0.0) · 21 int8 · 22 목차 내용 · 23 목차 쪽 · 24 목차 장 · 25 sum8(0.00 · 회색) · 26 표지 제목. - 판정 글(`O.K` · `N.G`) 칸 = 엔진 판정을 값으로 씀 · N.G 칸은 서식 10 · O.K 칸은 서식 11 을 직접 붙임 — 인쇄 HTML · xlsx 는 `조건부서식` 을 안 먹이므로(화면만). 양식의 `조건부서식`(글포함 `N.G` → 빨강)은 사용자가 화면에서 고친 판정 글용. - 수식이 보는 끝 행 = 20000(측점표 · 3D요약). 측점이 이보다 많으면 개요 수식의 `20000` 을 같이 늘림. - 빈 값은 칸을 안 적거나 `값` 을 안 둠(빈 글 `""` 금지). @@ -19,6 +22,16 @@ | C8:E8 · C9:E9 · C10:E10 · C11:E11 · C12:E12 | 사업명 · 노선명 · 발주처 · 작성일 · 작성자 값(병합 · 서식 5) — 왼위 칸 `C8` … `C12` 에 값 | | B14:E14 | 「문헌 추정치 · 현장 시험 없음」(고정 글 · 건드리지 않음) | +## s9 목차 (프로그램 몫) + +| 칸 | 내용 | +| --- | --- | +| `A1:C1` | 제목(고정 글) | +| 3 행 | 머리 — 장 · 내용 · 쪽 | +| 4 행부터 보이는 본문 시트 하나 = 한 줄 | A 장(`1.` … · 부록 = `부록`)(24) · B 내용(22) · C 시작 쪽(23 · 표지 뺀 본문 차례 · 목차 = 1) | + +- 쪽 번호 = 시트 하나씩 인쇄 → PDF 쪽 수로 셈(보고서 만들 때 · PDF 내보내기 때 다시) · 브라우저가 없으면 「-」. + ## s2 개요 (사용자 몫 · 수식은 양식에 들어 있음) - 빌더가 쓸 것: 없음(수식이 `측점표` · `입력조건` 을 가리킴). 의견 칸 `A22:F30`(병합 · 서식 15) = 사용자 글. @@ -34,11 +47,12 @@ | | `C12` `C13` `C14` (C:G 병합) | 비고 글 | | 지하수 규칙 | `B17` `B18` `B19` (B:G 병합) | 건기 · 우기 · 지진 시 규칙 글 | | 노면 하중 | `B22` | 수(kPa · 서식 16) | +| | `D22` (D:G 병합) | 비고 — 사용자 확정 전 표기 | | 지반 물성치 | **26 행부터 한 줄씩** · 머리 25 행(머리 줄 반복) | A 지층 · B 적용 구간(전체 또는 구간 글) · C γt · D γsat · E c · F φ(서식 16) · G 출처(서식 5) · A·B = 서식 7 | - 지반 물성치가 표의 맨 끝이라 줄 수가 늘어도 다른 칸이 안 밀림. 구간별 덮어쓰기 줄 = 같은 표에 줄 추가(B 열에 구간). -## s4 측점표 (프로그램 몫 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽) +## s4 측점표 (프로그램 몫 · 판정 일람표 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽) - 자료는 **6 행부터 측점 하나 = 한 줄** · 서식은 6 행 서식을 줄마다 그대로. @@ -50,9 +64,10 @@ | D · E · F | 건기 · 우기 · 지진 안전율 Fs(지진 끔 = 칸 없음) | 8 | | G · H · I | 건기 · 우기 · 지진 기준 안전율 | 8 | | J | 판정 `O.K` · `N.G`(엔진 판정 값) | N.G = 10 · O.K = 11 | +| K | 대책 필요(`대책 검토` · O.K = `-`) | 7 | - 개요 · 3D 수식이 `A6:A20000` · `B` · `D` · `E` · `F` · `J` 열을 읽음 → 이 열 배치 바꾸지 않음. -- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔은 자동(`인쇄.쪽나눔` 안 씀). +- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔 = 빌더가 쪽마다 고르게(`인쇄.쪽나눔` · 마지막 쪽에 한두 줄만 남지 않게). ## s5 대표단면 (프로그램 몫 · 단면 하나 = 28 행 덩이 = 한 쪽) @@ -62,14 +77,14 @@ | --- | --- | --- | | 1 | `A1:H1` 병합 | 단면 제목(`대표 단면 ① — 측점 · 구분 · 높이`) | | 2 | `B2:H2` 병합 | 선정 사유 글 | -| 3~22 | 그림 `A3:H22` | 그림 한 장 — 그림 칸 크기 약 712 × 533 px(가로 : 세로 = 4 : 3) · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 | +| 3~22 | 그림 `A3:H22` | 그림 한 장 — 행 24 pt · 그림 칸 712 × 640 px · 안전율 상자 · 지층 범례(무늬) · 축척 막대 · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 | | 23 | 머리(고정 글) | 조건 · 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · 중심 y | | 24 · 25 · 26 | A = 건기 · 우기 · 지진(고정 글) | B 해석 방법 글(서식 7) · C Fs(8) · D 기준(8) · E 판정(7→ N.G 10 · O.K 11) · F 반경 · G 중심 x · H 중심 y(13) | | 27 | `B27:H27` 병합 | 비고 글 | | 28 | 빈 줄 | 쪽 끝 | - `인쇄.쪽나눔` = `[28, 56, …, 28 × 단면 수]`(마지막 덩이 끝 쪽 나눔은 있어도 빈 쪽이 안 생김). -- 지진을 안 쓰면 26 행(덩이 안) 칸을 비움. +- 지진을 안 쓰면 26 행(덩이 안) B = 「검토 안 함」. 비원호 면 = F · G · H 「-」. 비고 끝 = 「부록 ⓝ 절편 계산표」. ## s6 3D요약 (프로그램 몫 · 머리 줄 19 행 반복) @@ -85,6 +100,7 @@ | 칸 | 내용 | | --- | --- | | `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 시나리오(표준 SINMAP 습윤지수 · 최악 완전 포화) · 해석 때 글 | +| `B9` (B:H 병합) | 기본값 그대로인 설정 · 물성 — 사용자 확정 전 한 줄 | | `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 | | `E11`~`G14` | 측점 등급 표 — 낮음 · 보통 · 높음별 SI<1 셀 비율 기준 글(7) · 측점 수(14) | | 22 행부터 보통 · 높음 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 측점 등급 + SI<1 비율 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | @@ -100,3 +116,17 @@ | 그 아래 벽 하나 × 경우 하나 = 한 줄 | A 측점(7) · B 벽 이름 · 평상시 / 지진 시 · 토압법 · 배면 수위(5) · C 활동 · E 전도 · G 지지력 Fs(8) · D · F · H 기준(8 · KDS 11 80 05) · I 판정(7 → 10 · 11) · 없으면 「벽 검토 없음」 | - 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나. + +## s10 부록_절편 (프로그램 몫 · 대표 단면마다 한 쪽) + +양식 1~5 행 = 덩이 꼴(1 제목 · 2 단위 · 3 머리 · 4 절편 줄 서식 · 5 합계 서식). 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면의 절편표. + +| 덩이 안 행 | 칸 | 내용 | +| --- | --- | --- | +| 1 | `A:M` 병합 | 「부록 ⓝ 절편 계산표 — 측점 · 구분 · 조건 · 방법 · Fs」 | +| 2 | `A:M` 병합 | 임계면 · 단위 글 | +| 3 | 머리 | 번호 · 폭 b · 높이 h · 저면 경사 α · 무게 W · 상재 Q · 간극수압 u · 저면 길이 l · c · φ · 유효 수직력 N' · 구동력 (W+Q)·sinα · 저항력 S | +| 4 … | 절편 하나 = 한 줄 | 서식 21(번호) · 20(α · φ) · 19(그 밖) | +| 끝 | 합계 | A 「합계」 · E ΣW · F ΣQ · L Σ구동력 · M Σ저항력(서식 25) | + +- 저항력 S = 동원 전단 × Fs = c·l + N'·tanφ · 덩이 끝마다 쪽 나눔 · 대표 단면 없음 = `A1` 「부록 — 절편 계산표 없음」. diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 5e3b36f1d..41da1f402 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -1,8 +1,10 @@ -"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`. +"""534-3 · 534-5(빌더 · storage · 길 · 인쇄) — E05 안정성평가 보고서 `E05_StabReport_Build` · +`_Section` · `_Print` · `_Store` · `_Router`. 가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · -갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수. +갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수 · +목차 쪽 번호 = 실제 · 부록 절편 · 측점표 쪽 나눔 고르게 · 옛 작업본 갱신(목차 자리 · 서식 붙임). """ import io @@ -15,11 +17,11 @@ from openpyxl import load_workbook from common_util.common_util_derived_store import derived_dir, write_derived from common_util.common_util_print_pdf import PrintError -from common_util.common_util_spreadsheet import workbook_pdf from common_util.common_util_workbook_xlsx import workbook_to_xlsx from E02_StabGround import E02_StabGround_Model as ground_model from E05_StabReport import E05_StabReport_Build as build from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport import E05_StabReport_Print as printer from E05_StabReport import E05_StabReport_Store as store pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -29,6 +31,7 @@ REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_heigh # 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔 OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False} KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station" +SLICES = 24 # 임계면 절편 수 REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}} @@ -82,9 +85,24 @@ def _write_summary(root: Path, fp: str, rows: list[dict]) -> None: write_derived(root, KIND_SUMMARY, fp, value) +def _slice(i: int) -> dict: + return { + "width": 0.5, "height": 1.0 + i / 10, "alpha_deg": 10.0 + i, "weight": 9.0 + i, + "surcharge": 0.0, "pore_pressure": 2.0, "base_length": 0.51, "c": 15.0, + "phi_deg": 30.0, "normal_eff": 8.0 + i, "shear_mobilized": 6.0 + i / 10, + } # fmt: skip + + def _slip(F: float) -> dict: pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)] - return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5} + return { + "F": F, + "kind": "circle", + "points": pts, + "center": [0.0, 112.0], + "radius": 12.5, + "slices": [_slice(i) for i in range(SLICES)], + } def _station_doc(i: int) -> dict: @@ -208,6 +226,29 @@ def test_처음_만듦_측점수_NG_그림(project): j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6] assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT) assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT) + k = [ + c.get("값") + for a1, c in by["측점표"]["칸"].items() + if a1.startswith("K") and int(a1[1:]) >= 6 + ] + assert k.count("대책 검토") == len(NG_AT) and k.count("-") == N - len(NG_AT) + # 측점표 쪽 나눔 = 고르게(덩이 크기 차 ≤ 1 · 한 줄 꼬리 없음) + cuts = by["측점표"]["인쇄"]["쪽나눔"] + sizes = [b - a for a, b in zip([5, *cuts], [*cuts, 5 + N])] + assert len(sizes) >= 2 and max(sizes) - min(sizes) <= 1 + # 부록 절편 — 대표 단면마다 지배 조건 한 덩이 · 저항력 = 동원 전단 × Fs · 덩이 끝 쪽 나눔 + s10 = by["부록_절편"]["칸"] + block = 3 + SLICES + 1 + assert by["부록_절편"]["인쇄"]["쪽나눔"] == [block * (i + 1) for i in range(len(REPS))] + assert "건기" in s10["A1"]["값"] and s10["A1"]["값"].startswith("부록 ① 절편 계산표 — No.3") + assert s10["A4"]["값"] == 1 and s10["M4"]["값"] == pytest.approx(6.0 * 1.31) + assert s10[f"A{block}"]["값"] == "합계" + assert s10[f"E{block}"]["값"] == pytest.approx(sum(9.0 + i for i in range(SLICES))) + assert book["서식"][s10["B4"]["서식"]]["크기"] == 8 + # 목차 — 본문 시트 차례 · 쪽 번호(목차 = 1) + toc = by["목차"]["칸"] + assert [toc[f"B{r}"]["값"] for r in (4, 5)] == [printer.TITLES["s2"], printer.TITLES["s3"]] + assert toc["A10"]["값"] == "부록" # 보강검토 없음 → 본문 6 + 부록 and toc["C4"]["값"] == 2 figs = sorted((project / "E05_StabReport" / "fig").glob("*.png")) assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS] @@ -356,6 +397,29 @@ def test_갱신_사용자몫_불변_덮임(project): ) +def test_옛_작업본_갱신_목차자리_서식(project): + """534-5 전 작업본(목차 · 부록 없음 · 서식 18 개) → 갱신 = 목차는 표지 다음 · 부록은 끝 · + 새 서식 붙임(옛 서식 번호 그대로) · 사용자 몫 불변.""" + _create(project) + book, meta = store.load(project) + book["시트"] = [s for s in book["시트"] if s["id"] not in ("s9", "s10")] + book["서식"] = book["서식"][:18] + [{"크기": 30}] # 사용자가 더한 서식 = 18 번 + _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 18} + meta["signs"] = {k: v for k, v in meta["signs"].items() if k not in ("s9", "s10")} + store.save(project, book) + (project / "E05_StabReport" / build.META).write_text(json.dumps(meta), encoding="utf-8") + store.refresh(project) + book, _ = store.load(project) + ids = [s["id"] for s in book["시트"]] + assert ids[:3] == ["s1", "s9", "s2"] and ids[-1] == "s10" + by = _sheets(book) + assert by["표지"]["칸"]["C10"] == {"값": "산림청", "서식": 18} and book["서식"][18] == { + "크기": 30 + } + head = book["서식"][by["부록_절편"]["칸"]["A3"]["서식"]] + assert head["크기"] == 8 and head["굵게"] and len(book["서식"]) == len(build.form()["서식"]) + 1 + + def test_3D_없음_2D_없음(project): shutil.rmtree(project / "E04_Stab3D") inputs = build.gather(project) @@ -375,24 +439,36 @@ def test_xlsx_되읽기_pdf_쪽수(project): figs = store.figures_of(project, book) assert len(figs) == len(REPS) + 1 wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs))) - assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"] + assert wb.sheetnames == [ + "표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "부록_절편", + ] # fmt: skip assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1 assert [b.id for b in wb["대표단면"].row_breaks.brk] == [28, 56, 84] assert wb["측점표"]["J9"].value == "N.G" and wb["측점표"]["J9"].fill.fgColor.rgb.endswith( "FFC7CE" ) - book.pop("계산값", None) try: - pdf = workbook_pdf(book, figs) + pdf = printer.report_pdf(book, figs) except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") from pypdf import PdfReader texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages] stab = [i for i, t in enumerate(texts) if "산정 안전율" in t] - assert stab and all("산정 안전율" in texts[i] for i in stab) - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 - assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1 + assert len(stab) == len(book["시트"][4]["인쇄"]["쪽나눔"]) + 1 # 측점표 = 나눈 덩이 수 + # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + 부록 3 + assert len(texts) == 4 + len(stab) + len(REPS) + 1 + 1 + len(REPS) + body = len(texts) - 1 + assert f"/ {body}" not in texts[0] and "검증 임도" not in texts[0].split("보고서")[0] + assert all(f"- {i} / {body} -" in texts[i] and "검증 임도" in texts[i] for i in range(1, body)) + # 목차 쪽 번호 = 실제 쪽(본문 n 쪽 = PDF n + 1 번째 쪽) + toc = texts[1] + marks = {"측점별 안정성 판정 일람표": "산정 안전율", printer.TITLES["s5"]: "대표 단면 ①"} + marks[printer.TITLES["s10"]] = "절편 계산표" + for title, inside in marks.items(): + line = next(x for x in toc.splitlines() if title in x) + page = int(line.split()[-1]) + assert inside in texts[page], (title, page) def test_단면_그림_SVG(): @@ -430,7 +506,7 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch): got = client.get(url).json() except PrintError as error: pytest.skip(f"브라우저 없음 — {error}") - assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7 + assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 9 book = got["book"] _sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5} assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409 diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 0a6681409..5e983c265 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -36,7 +36,10 @@ STYLE_KEYS = { } # fmt: skip PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"} A1 = re.compile(r"^([A-Z]{1,3})(\d+)$") -NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토"] +NAMES = [ + "표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토", + "부록_절편", +] # fmt: skip needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -148,7 +151,8 @@ def test_양식_계약(): book = _form() _contract(book) assert [s["이름"] for s in book["시트"]] == NAMES - assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"] + ids = ["s1", "s9", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s10"] + assert [s["id"] for s in book["시트"]] == ids def test_양식_인쇄_쪽나눔_머리행_그림(): @@ -161,6 +165,8 @@ def test_양식_인쇄_쪽나눔_머리행_그림(): figs = {n: len(s.get("그림", [])) for n, s in by.items()} assert figs == { "표지": 0, + "목차": 0, + "부록_절편": 0, "개요": 0, "입력조건": 0, "측점표": 0, @@ -231,8 +237,9 @@ def test_견본_pdf_쪽수_머리행(png): ) sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t] assert len(sections) == 2 # 단면마다 한 쪽 - # 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1(양식 빈 판) - assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 + # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1 + 부록 1 + # (양식 빈 판) + assert len(pages) == 1 + 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 + 1 assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab) html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0] assert html.count("