diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py index f62bb196e..843206f31 100644 --- a/E05_StabReport/E05_StabReport_Area.py +++ b/E05_StabReport/E05_StabReport_Area.py @@ -15,6 +15,18 @@ from E07_StabArea.E07_StabArea_Router import RESULT, load CLASS_NAMES = ("", "안정", "보통 안정", "준안정", "위험 하한", "위험 상한", "불안정") +def scenario_text(s: dict[str, Any]) -> str: + """시나리오 글 — 화면 시나리오 이름과 같은 말.""" + if s.get("scenario") == "saturated": + return "최악 — 완전 포화(m = 1 · 강우 극한)" + if s.get("scenario") == "sinmap": + return ( + f"표준 — SINMAP 습윤지수 W = min(R·a / (T·sinθ), 1) · " + f"T/R {s['tr_lo']:g}~{s['tr_hi']:g} m" + ) + return f"옛 조건({s.get('scenario')}) — E07 에서 [다시 해석]" + + def stamp(project_root: Path) -> list[int] | None: """해석 지문용 — 저장본 크기 · 고친 때.""" path = Path(project_root) / RESULT @@ -51,12 +63,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f f"φ {s['phi']:g}~{s['phi_hi']:g}° · 뿌리 점착 {s['c_root']:g} kPa · " f"토층 {s['soil_depth_m']:g} m", ) - rain = ( - "지표 포화(m = 1)" - if s["scenario"] == "saturated" - else f"강우 {s['rain_mm_h']:g} mm/h · 투수계수 {s['k_m_s']:g} m/s(정상류 포화비)" - ) - _set(sheet, "B7", rain) + _set(sheet, "B7", scenario_text(s)) _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) for k in range(1, 7): _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py index add8f867a..747dc3281 100644 --- a/E07_StabArea/E07_StabArea_Engine.py +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -5,12 +5,14 @@ 지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D8 흐름(웅덩이 채움 · 수신 셀 = B04 배수유역 함수 재사용) · 단위 집수면적 a = 상류 셀 수 × cell²/cell(등고선 폭 = cell) - 포화비 m = 1(지표 포화 · 기본) | min(R·a / (T·sinβ), 1) · T = K·z(정상류 · SINMAP · SHALSTAB) + 포화비 m 시나리오 둘 + sinmap(기본 · SINMAP 표준) = 습윤지수 W = min(x·a / sinβ, 1) · x = R/T ∈ [1/(T/R)_hi, 1/(T/R)_lo] + saturated(최악 · 강우 극한) = 지표 포화 m = 1 안전율 Fs = (c + c_r + [γt z(1−m) + (γsat−γw) m z] cos²β tanφ) / ([γt z(1−m) + γsat m z] sinβ cosβ) - 안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] 고른 분포 - Fs_min ≥ 1 → SI = Fs_min · Fs_max < 1 → SI = 0 - · 그 사이 → SI = P(Fs > 1)(두 고른 분포 합 · 닫힌 꼴) + 안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] · x ∈ [x_lo, x_hi] 고른 분포 + 하한 Fs_min = (c, tanφ, x_hi 가장 젖음) ≥ 1 → SI = Fs_min · 상한 Fs_max = (c_hi, tanφ_hi, x_lo) < 1 + → SI = 0 · 그 사이 → SI = P(Fs > 1)(c · tanφ 닫힌 꼴 · x 는 중점 적분 X_STEPS 마디) 등급 1 안정(SI ≥ 1.5) · 2 보통 안정(≥ 1.25) · 3 준안정(≥ 1.0) · 4 위험 하한(≥ 0.5) · 5 위험 상한(> 0) · 6 불안정(SI = 0) · 0 = 자료 없음 @@ -38,6 +40,7 @@ SI_EDGES = (1.5, 1.25, 1.0, 0.5) # 등급 1~4 아래 경계 · 5 = (0, 0.5) · CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended") RISK_CLASS = 4 # 이 등급부터 위험(SI < 1) FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞) +X_STEPS = 20 # SI 확률의 x = R/T 중점 적분 마디 @dataclass(frozen=True) @@ -52,9 +55,9 @@ class AreaParams: phi_hi: float c_root: float = 0.0 # 뿌리 점착(나지 = 0) soil_depth_m: float = 1.5 - scenario: str = "saturated" # saturated = 지표 포화 m = 1 · rain = 정상류 강우 - rain_mm_h: float = 30.0 - k_m_s: float = 1e-4 # 포화 투수계수 + scenario: str = "sinmap" # sinmap = 습윤지수(T/R 범위) · saturated = 지표 포화 m = 1(최악) + tr_lo: float = 2000.0 # T/R 아래(m · 젖은 쪽) + tr_hi: float = 3000.0 # T/R 위(m · 마른 쪽) # --------------------------------------------------------------------------- @@ -119,15 +122,23 @@ def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: # --------------------------------------------------------------------------- -def wetness(beta: np.ndarray, a: np.ndarray, p: AreaParams) -> np.ndarray: - """포화비 m(0~1) — 지표 포화 = 1 · 강우 = min(R·a/(T sinβ), 1).""" +def wetness(beta: np.ndarray, a: np.ndarray, x: float) -> np.ndarray: + """습윤지수 W = min(x·a / sinβ, 1) · x = R/T(1/m).""" + with np.errstate(divide="ignore", invalid="ignore"): + m = x * a / np.sin(beta) + return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + + +def x_range(p: AreaParams) -> tuple[float, float]: + """x = R/T 범위(마른 쪽, 젖은 쪽) — T/R 위 · 아래의 역수.""" + return 1.0 / max(p.tr_hi, p.tr_lo), 1.0 / p.tr_lo + + +def wet_m(beta: np.ndarray, a: np.ndarray, p: AreaParams, x: float | None = None) -> np.ndarray: + """시나리오 포화비 — saturated = 1 · sinmap = W(x · 없으면 가장 젖은 x_hi).""" if p.scenario == "saturated": return np.ones_like(beta) - recharge = p.rain_mm_h / 1000.0 / 3600.0 # m/s - transmissivity = p.k_m_s * p.soil_depth_m # m²/s - with np.errstate(divide="ignore", invalid="ignore"): - m = recharge * a / (transmissivity * np.sin(beta)) - return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + return wetness(beta, a, x_range(p)[1] if x is None else x) def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]: @@ -173,18 +184,33 @@ def _cdf_sum_uniform( return np.clip(np.nan_to_num(out, nan=0.0), 0.0, 1.0) -def stability_index(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: - """SINMAP 안정 지수 — Fs_min ≥ 1 → Fs_min · Fs_max < 1 → 0 · 그 사이 → P(Fs > 1).""" - a, b = _terms(beta, m, p) +def stability_index(beta: np.ndarray, area: np.ndarray, p: AreaParams) -> np.ndarray: + """SINMAP 안정 지수 — 하한 Fs_min ≥ 1 → Fs_min · 상한 Fs_max < 1 → 0 · 그 사이 → P(Fs > 1).""" t1, t2 = math.tan(math.radians(p.phi)), math.tan(math.radians(max(p.phi_hi, p.phi))) c1, c2 = p.c + p.c_root, max(p.c_hi, p.c) + p.c_root - flat = np.isinf(a) - a = np.where(flat, 0.0, a) - b = np.where(flat, 0.0, b) - lo, hi = c1 * a + t1 * b, c2 * a + t2 * b - prob = 1.0 - _cdf_sum_uniform(np.ones_like(a), c1 * a, c2 * a, t1 * b, t2 * b) + x_lo, x_hi = x_range(p) + + def ab(m: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + a, b = _terms(beta, m, p) + return np.where(np.isinf(a), 0.0, a), np.where(np.isinf(b), 0.0, b) + + a, b = ab(wet_m(beta, area, p, x_hi)) + lo = c1 * a + t1 * b + a, b = ab(wet_m(beta, area, p, x_lo)) + hi = c2 * a + t2 * b + xs = ( + [x_hi] + if p.scenario == "saturated" + else x_lo + (x_hi - x_lo) * (np.arange(X_STEPS) + 0.5) / X_STEPS + ) + prob = np.zeros_like(beta, dtype=float) + one = np.ones_like(prob) + for x in xs: + a, b = ab(wet_m(beta, area, p, x)) + prob += 1.0 - _cdf_sum_uniform(one, c1 * a, c2 * a, t1 * b, t2 * b) + prob /= len(xs) si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob)) - return np.where(flat, np.inf, si) + return np.where(beta < FLAT_SLOPE_RAD, np.inf, si) def classify(si: np.ndarray) -> np.ndarray: @@ -200,10 +226,10 @@ def classify(si: np.ndarray) -> np.ndarray: def analyze_grid(z: np.ndarray, cell_m: float, p: AreaParams) -> dict[str, np.ndarray]: """고른 격자 지형 → {slope(rad) · a · m · fs · si · cls} · 밖 = NaN / 0.""" beta = slope_rad(z, cell_m) - a = catchment_area(z, cell_m) if p.scenario == "rain" else np.full(z.shape, np.nan) - m = wetness(beta, a, p) + a = catchment_area(z, cell_m) if p.scenario != "saturated" else np.full(z.shape, np.nan) + m = wet_m(beta, a, p) # 가장 젖은 쪽(Fs = 하한과 같은 조건) fs = infinite_slope_fs(beta, m, p) - si = stability_index(beta, m, p) + si = stability_index(beta, a, p) nodata = np.isnan(beta) fs[nodata] = np.nan si[nodata] = np.nan diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index dfae623ff..21b512ec9 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -46,7 +46,7 @@ from E07_StabArea import E07_StabArea_Engine as engine logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"]) -ENGINE_VERSION = "e07-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_VERSION = "e07-2" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 RESULT = Path("E07_StabArea") / "_derived" / "area.json" DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) RANGE_C_KPA, RANGE_PHI_DEG = ( @@ -69,17 +69,19 @@ class AreaRequest(BaseModel): c_hi: float = Field(..., ge=0.0, le=500.0) phi: float = Field(..., ge=0.0, le=60.0) phi_hi: float = Field(..., ge=0.0, le=60.0) - c_root: float = Field(0.0, ge=0.0, le=50.0) + # 기본값 = 근거 후보(바꿀 수 있음 · 확정은 사용자) — 출처 = 지식DB 예정노선/02 §1.1 · + # SINMAP 2 설명서 기본 보정 · 국내 적용 연구(김동엽 2026 봉화 TRIGRS Cr 0~5 kPa · 우면산 1 kPa) + c_root: float = Field(1.0, ge=0.0, le=50.0) # 산림 뿌리 점착 · 나지 · 절성토면 = 0 soil_depth_m: float = Field(1.5, ge=0.1, le=10.0) - scenario: Literal["saturated", "rain"] = "saturated" - rain_mm_h: float = Field(30.0, ge=0.0, le=300.0) - k_m_s: float = Field(1e-4, ge=1e-8, le=1e-1) + scenario: Literal["sinmap", "saturated"] = "sinmap" # 표준(습윤지수) · 최악(완전 포화) + tr_lo: float = Field(2000.0, ge=1.0, le=1e5) # T/R(m) — SINMAP 기본 보정 2000~3000 + tr_hi: float = Field(3000.0, ge=1.0, le=1e5) force: bool = False @model_validator(mode="after") def _ranges(self) -> AreaRequest: - if self.c_hi < self.c or self.phi_hi < self.phi: - raise ValueError("범위 위가 아래보다 작습니다(c · φ).") + if self.c_hi < self.c or self.phi_hi < self.phi or self.tr_hi < self.tr_lo: + raise ValueError("범위 위가 아래보다 작습니다(c · φ · T/R).") return self @@ -167,8 +169,8 @@ def run( "c_root", "soil_depth_m", "scenario", - "rain_mm_h", - "k_m_s", + "tr_lo", + "tr_hi", ) out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys})) report(75, "노선 띠 · 측점") diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index f31216547..7e7726d2e 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -1,7 +1,7 @@ /* ============================================================================= * E07_StabArea_UI_Page.ts * E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow). - * 좌 = 토층 물성(E02 물성 고름 · 범위) · 강우(지표 포화 · 강우 강도) · 노선 띠 · [해석] · 진행률 + * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. @@ -67,8 +67,8 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ ["phi_hi", "E07_PhiHi", "soil"], ["c_root", "E07_CRoot", "soil"], ["soil_depth_m", "E07_Depth", "soil"], - ["rain_mm_h", "E07_Rain", "rain"], - ["k_m_s", "E07_K", "rain"], + ["tr_lo", "E07_TrLo", "rain"], + ["tr_hi", "E07_TrHi", "rain"], ["band_m", "E07_Band", "band"], ["cell_m", "E07_Cell", "band"], ]; @@ -100,9 +100,15 @@ function swatch(color: string): HTMLElement { return chip; } -function legend(layer: MapLayer): HTMLElement { +const SCENARIOS = ["sinmap", "saturated"]; +const scenarioName = (value: unknown): string => + SCENARIOS.includes(String(value)) ? t(`E07_Scenario_${value}` as LocaleKey) : String(value ?? ""); + +function legend(layer: MapLayer, scenario: unknown): HTMLElement { const box = el("div", { className: "e07-legend" }); box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); + if (scenario) + box.append(el("small", { className: "e07-legend__sub", text: scenarioName(scenario) })); const rows = layer === "cls" ? CLASS_KEYS.map((k, i) => ({ @@ -136,9 +142,11 @@ export function renderE07StabArea(root: HTMLElement): void { // ---- 가운데 ---- const map = createAreaMap(); + let overview: Overview | null = null; let layer: MapLayer = "cls"; const legendSlot = el("div", { className: "e07-legend-slot" }); - const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); + const paintLegend = (): void => + legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario)); const toggle = el("div", { className: "e07-toggle" }); const layerButtons = ( [ @@ -187,14 +195,12 @@ export function renderE07StabArea(root: HTMLElement): void { const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); const scenario = createSelectField({ label: t("E07_Scenario"), - options: [ - { value: "saturated", text: t("E07_Scenario_saturated") }, - { value: "rain", text: t("E07_Scenario_rain") }, - ], + options: SCENARIOS.map((value) => ({ value, text: scenarioName(value) })), compact: true, }); cards.soil.append(material.root); cards.rain.append(scenario.root); + const sourceHint = el("p", { className: "e07-hint", text: t("E07_Defaults_Source") }); for (const [key, label, card] of NUMBER_FIELDS) { const field = createInputField({ label: t(label), type: "number", compact: true }); field.input.step = "any"; @@ -232,13 +238,13 @@ export function renderE07StabArea(root: HTMLElement): void { cards.soil, cards.rain, cards.band, + sourceHint, progressBox, actions, ); attachCollapsible(panel); // ---- 상태 ---- - let overview: Overview | null = null; let running = false; function read(): Settings { @@ -251,14 +257,16 @@ export function renderE07StabArea(root: HTMLElement): void { function write(values: Settings): void { for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); material.select.value = String(values.material_id ?? ""); - scenario.select.value = String(values.scenario ?? "saturated"); + scenario.select.value = SCENARIOS.includes(String(values.scenario)) + ? String(values.scenario) + : SCENARIOS[0]; paintScenario(); remember(); } function paintScenario(): void { - const rain = scenario.select.value === "rain"; - for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain; + const wet = scenario.select.value === "sinmap"; + for (const key of ["tr_lo", "tr_hi"]) fields.get(key)!.root.hidden = !wet; map.setBand(Number(fields.get("band_m")!.input.value) || 100); } @@ -299,6 +307,7 @@ export function renderE07StabArea(root: HTMLElement): void { const s = result.summary; const list = el("dl", { className: "e07-summary" }); const pairs: [LocaleKey, string][] = [ + ["E07_Scenario", scenarioName(result.settings.scenario)], ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`], ["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`], @@ -404,6 +413,7 @@ export function renderE07StabArea(root: HTMLElement): void { map.setResult(overview.result ? decode(overview.result) : null); paintButtons(); paintResult(); + paintLegend(); } async function poll(): Promise { @@ -463,7 +473,8 @@ export function renderE07StabArea(root: HTMLElement): void { const cached = JSON.parse( sessionStorage.getItem(cacheKey(projectId)) ?? "null", ) as Settings | null; - write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults); + // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 + write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) }); map.setRoute(ov.line, ov.stations); const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); if (p?.running) void poll(); diff --git a/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css index ee82afbc2..55436dfab 100644 --- a/E07_StabArea/E07_StabArea_UI_Style.css +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -96,6 +96,11 @@ font-size: 12px; } +.e07-legend__sub { + max-width: 180px; + color: var(--color-text-muted); +} + .e07-legend__row { display: flex; align-items: center; diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 42a350b7e..0b20ea6d6 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -2731,7 +2731,7 @@ "서식": 5 }, "A7": { - "값": "강우 · 포화", + "값": "시나리오 · 포화", "서식": 4 }, "B7": { diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index b9bafb828..c2d8b4d7a 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -84,7 +84,7 @@ | 칸 | 내용 | | --- | --- | -| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 강우 · 포화 · 해석 때 글 | +| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 시나리오(표준 SINMAP 습윤지수 · 최악 완전 포화) · 해석 때 글 | | `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 | | 22 행부터 위험 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 가장 나쁜 등급 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 | diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 9c486114b..19896df36 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -239,9 +239,9 @@ def test_광역요약_E07_저장본(project): "phi_hi": 30.0, "c_root": 0.0, "soil_depth_m": 1.5, - "scenario": "saturated", - "rain_mm_h": 30.0, - "k_m_s": 1e-4, + "scenario": "sinmap", + "tr_lo": 2000.0, + "tr_hi": 3000.0, }, "computed_at": "2026-10-25T21:00:00", "stations": [ @@ -288,7 +288,9 @@ def test_광역요약_E07_저장본(project): assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0] assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["No.1", "No.2"] and "A24" not in s7 assert ( - s7["E22"]["값"] == "불안정" and s7["F23"]["값"] == 8.0 and s7["B7"]["값"].startswith("지표") + s7["E22"]["값"] == "불안정" + and s7["F23"]["값"] == 8.0 + and s7["B7"]["값"].startswith("표준 — SINMAP") ) vals = book["계산값"]["s7"] assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45") diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index b72bf11c4..f56853037 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -1,7 +1,8 @@ """536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`. 무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) · -D8 집수면적 견본(기운 평면 · V 골짜기) · 강우 포화비 · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. +D8 집수면적 견본(기운 평면 · V 골짜기) · 습윤지수 · SI 하한 / 상한 해석해 · 시나리오 둘(표준 · 최악) · +가짜 복사본으로 띠 · 측점표 · 저장본 · 지문. """ import base64 @@ -15,6 +16,7 @@ from E07_StabArea import E07_StabArea_Engine as engine from E07_StabArea import E07_StabArea_Router as router_mod SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0) +SAT = dict(scenario="saturated") def _fs(beta_deg: float, m: float, **kw) -> float: @@ -36,7 +38,9 @@ def test_무한사면_해석해_건조_포화_점착(): def test_SINMAP_안정지수_닫힌꼴_표본(): - p = engine.AreaParams(gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0) + p = engine.AreaParams( + gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0, **SAT + ) beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0])) m = np.ones_like(beta) si = engine.stability_index(beta, m, p) @@ -55,7 +59,7 @@ def test_SINMAP_안정지수_닫힌꼴_표본(): assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1) assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남 # 폭 0(범위 없음) — 결정 Fs 와 같거나 0 - q = engine.AreaParams(**SOIL) + q = engine.AreaParams(**SOIL, **SAT) fs = engine.infinite_slope_fs(beta, m, q) si0 = engine.stability_index(beta, m, q) assert np.allclose(si0, np.where(fs >= 1, fs, 0.0)) @@ -80,14 +84,75 @@ def test_D8_집수면적_기운평면_V골짜기(): assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남 -def test_강우_포화비(): - p = engine.AreaParams(**SOIL, scenario="rain", rain_mm_h=36.0, k_m_s=1e-4, soil_depth_m=2.0) - beta = np.radians(np.array([30.0, 30.0])) - a = np.array([10.0, 1e6]) - m = engine.wetness(beta, a, p) - assert m[0] == pytest.approx(1e-5 * 10 / (2e-4 * 0.5)) # R a / (T sinβ) - assert m[1] == 1.0 - assert engine.wetness(beta, a, engine.AreaParams(**SOIL)).tolist() == [1.0, 1.0] +def test_습윤지수(): + beta = np.radians(np.array([30.0, 30.0, 0.0])) + a = np.array([100.0, 1e6, 50.0]) + w = engine.wetness(beta, a, 1 / 2000) + assert w[0] == pytest.approx(100 / (2000 * 0.5)) # R a / (T sinβ) + assert w[1] == 1.0 and w[2] == 1.0 # 넘침 · 평평 = 포화 + p = engine.AreaParams(**SOIL, tr_lo=2000, tr_hi=3000) + assert engine.x_range(p) == (1 / 3000, 1 / 2000) + assert engine.wet_m(beta, a, p).tolist() == w.tolist() # 기본 = 가장 젖은 쪽 + assert engine.wet_m(beta, a, engine.AreaParams(**SOIL, **SAT)).tolist() == [1.0] * 3 + + +SINMAP = dict(gamma_t=18.0, gamma_sat=18.0, c=2.0, c_hi=6.0, phi=30.0, phi_hi=40.0) + + +def _sinmap_fs(beta, a, c, tanphi, x, z=1.5, g=18.0): + """Pack 1998 식 — FS = [C + cosθ(1 − W r) tanφ] / sinθ · C = c / (h ρs g) · h = z cosθ.""" + w = min(x * a / math.sin(beta), 1.0) + h = z * math.cos(beta) + return (c / (h * g) + math.cos(beta) * (1 - w * 9.81 / g) * tanphi) / math.sin(beta) + + +def test_SI_하한_상한_해석해(): + """γt = γsat 이면 엔진 = SINMAP 원식 · 하한 = (c, φ 아래 · T/R 아래) · 상한 = (위 · T/R 위).""" + p = engine.AreaParams(**SINMAP, tr_lo=1000, tr_hi=3000) + t1, t2 = math.tan(math.radians(30)), math.tan(math.radians(40)) + beta = np.radians(np.array([15.0, 33.0, 38.0, 45.0, 70.0])) + area = np.array([20.0, 400.0, 300.0, 800.0, 5000.0]) + si = engine.stability_index(beta, area, p) + rng = np.random.default_rng(7) + n = 300_000 + c, t, x = rng.uniform(2, 6, n), rng.uniform(t1, t2, n), rng.uniform(1 / 3000, 1 / 1000, n) + seen = set() + for k, (b, ak) in enumerate(zip(beta, area)): + lo = _sinmap_fs(b, ak, 2.0, t1, 1 / 1000) + hi = _sinmap_fs(b, ak, 6.0, t2, 1 / 3000) + fs = engine.infinite_slope_fs( + beta[k : k + 1], engine.wet_m(beta[k : k + 1], area[k : k + 1], p), p + ) + assert fs[0] == pytest.approx(lo, rel=1e-12) # 결정 Fs = 하한 + if lo >= 1: + assert si[k] == pytest.approx(lo, rel=1e-12) + seen.add("lo") + elif hi < 1: + assert si[k] == 0.0 + seen.add("zero") + else: + w = np.minimum(x * ak / math.sin(b), 1) + h = 1.5 * math.cos(b) + mc = (c / (h * 18) + math.cos(b) * (1 - w * 9.81 / 18) * t) / math.sin(b) + assert si[k] == pytest.approx((mc > 1).mean(), abs=3e-3) # P(Fs > 1) · T/R 도 범위 + seen.add("prob") + assert seen == {"lo", "zero", "prob"} + + +def test_시나리오_둘_최악이_더_나쁨(): + beta = np.radians(np.linspace(5, 60, 56)) + area = np.full_like(beta, 50.0) + std = engine.stability_index(beta, area, engine.AreaParams(**SINMAP)) + worst = engine.stability_index(beta, area, engine.AreaParams(**SINMAP, **SAT)) + assert np.all(worst <= std + 1e-12) # 완전 포화 = 아래 경계 + assert (engine.classify(worst) >= engine.RISK_CLASS).sum() > ( + engine.classify(std) >= engine.RISK_CLASS + ).sum() + # 다 젖는 집수(x·a / sinβ ≥ 1) 면 두 시나리오가 같음 + big = np.full_like(beta, 1e7) + assert np.allclose( + engine.stability_index(beta, big, engine.AreaParams(**SINMAP)), worst, rtol=1e-12 + ) def _decode(text: str, dtype: str, grid: dict) -> np.ndarray: @@ -126,7 +191,9 @@ def project(tmp_path, monkeypatch): def test_길_띠_측점표_저장본(project): - settings = {**router_mod.defaults(project), "band_m": 30.0, "cell_m": 2.0} + base = router_mod.defaults(project) + assert base["scenario"] == "sinmap" and (base["tr_lo"], base["tr_hi"]) == (2000.0, 3000.0) + settings = {**base, "band_m": 30.0, "cell_m": 2.0, "scenario": "saturated", "c_root": 0.0} assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5 steps = [] summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p)) diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts index 036187a13..cf7486a54 100644 --- a/ui_template/ui_template_locale_e07.ts +++ b/ui_template/ui_template_locale_e07.ts @@ -16,7 +16,7 @@ export const ui_locales_e07 = { ], E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E07_Card_Soil: ["토층 물성", "Soil layer"], - E07_Card_Rain: ["강우 · 포화", "Rain · saturation"], + E07_Card_Rain: ["시나리오 · 포화", "Scenario · saturation"], E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"], E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"], E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"], @@ -28,10 +28,16 @@ export const ui_locales_e07 = { E07_CRoot: ["뿌리 점착 (kPa)", "Root cohesion (kPa)"], E07_Depth: ["토층 두께 (m)", "Soil depth (m)"], E07_Scenario: ["시나리오", "Scenario"], - E07_Scenario_saturated: ["지표 포화 (m = 1)", "Fully saturated (m = 1)"], - E07_Scenario_rain: ["강우 강도 (정상류)", "Rainfall (steady state)"], - E07_Rain: ["강우 강도 (mm/h)", "Rainfall (mm/h)"], - E07_K: ["투수계수 (m/s)", "Hydraulic conductivity (m/s)"], + E07_Scenario_sinmap: [ + "표준 — SINMAP 습윤지수 (T/R 범위)", + "Standard — SINMAP wetness (T/R range)", + ], + E07_Scenario_saturated: [ + "최악 — 완전 포화 (m = 1 · 강우 극한)", + "Worst — fully saturated (m = 1 · extreme rain)", + ], + E07_TrLo: ["T/R 아래 (m · 젖은 쪽)", "T/R low (m · wetter)"], + E07_TrHi: ["T/R 위 (m · 마른 쪽)", "T/R high (m · drier)"], E07_Band: ["띠 폭 ± (m)", "Band half-width (m)"], E07_Cell: ["격자 (m)", "Cell (m)"], E07_Defaults: ["기본값", "Defaults"], @@ -63,8 +69,8 @@ export const ui_locales_e07 = { "The engine changed — re-analyze.", ], E07_Result_Note: [ - "문헌 추정치 · 현장 시험 없음. SI = c · φ 범위 안에서 Fs > 1 일 확률(SI ≥ 1 이면 가장 나쁜 값의 Fs). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.", - "Literature values, no field tests. SI = probability of Fs > 1 over the c · φ ranges (worst-case Fs when SI ≥ 1). Upslope area beyond the terrain grid is not counted.", + "문헌 추정치 · 현장 시험 없음 · 기본값은 바꿀 수 있음. 표준 = 포화비 W = min(R·a / (T·sinθ), 1). SI = c · φ · T/R 범위 안에서 Fs > 1 일 확률(하한 Fs ≥ 1 이면 그 값 · 상한 Fs < 1 이면 0). Fs = 가장 나쁜 쪽(c · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.", + "Literature values, no field tests; defaults are editable. Standard = wetness W = min(R·a / (T·sinθ), 1). SI = probability of Fs > 1 over the c · φ · T/R ranges (lower-bound Fs when ≥ 1, 0 when upper-bound Fs < 1). Fs = worst case (low c · φ, wettest). Upslope area beyond the terrain grid is not counted.", ], E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"], E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"], @@ -80,4 +86,8 @@ export const ui_locales_e07 = { E07_Col_SiMin: ["최소 SI", "Min SI"], E07_Col_Worst: ["나쁜 등급", "Worst class"], E07_Col_RiskArea: ["위험 m²", "Hazard m²"], + E07_Defaults_Source: [ + "기본값 = 근거 후보 · 바꿀 수 있음 — 물성 = E02 붕적토 · T/R 2,000~3,000 m = SINMAP 설명서 기본 보정 · 뿌리 점착 1 kPa = 국내 연구(산림 0~5 kPa · 나지 · 절성토면 0).", + "Defaults are evidence-based candidates and editable — soil = E02 colluvium · T/R 2,000–3,000 m = SINMAP manual default · root cohesion 1 kPa = Korean studies (forest 0–5 kPa · bare / cut slopes 0).", + ], } as const satisfies Record;