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;