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