feat(536-2): E07 광역 위험 기본값 재검토 — 기본 = SINMAP 표준 습윤지수(T/R 2,000~3,000 m · SI 하한/상한/확률) · 완전 포화 = 최악 선택지 · 뿌리 점착 1 kPa · E05 광역요약 · 범례 시나리오 이름(요구 351)

- 엔진: 포화비 W = min(x·a/sinβ, 1) · x = R/T 범위 · SI = 하한(c · φ 아래 · 가장 젖음) ≥ 1 → 그 값 · 상한 < 1 → 0 · 사이 = P(Fs > 1)(c · tanφ 닫힌 꼴 · x 중점 적분 20 마디)
- 옛 「강우 강도(정상류)」 시나리오 뺌 — 첨두 강우를 정상류 함양으로 넣어 거의 다 포화 · T/R 로 갈음 · 엔진 판 e07-2(옛 결과 = 다시 해석)
- 기본값 출처 = SINMAP 2 설명서 기본 보정 · 국내 적용 연구(뿌리 0~5 kPa) · 지식DB 예정노선/02 · 바꿀 수 있음
- 시험 536-2: 강우 포화비 시험을 습윤지수 · SI 하한/상한 Pack 원식 · 시나리오 둘로 바꿈 · 534-3 광역요약 B7 글 맞춤
- 검증 노선: 위험(SI<1) 91.7 % → 11.1 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 23:17:20 +09:00
co-authored by Claude Opus 5.5
parent 2ba995b8de
commit cdd9fa3c0b
10 changed files with 210 additions and 80 deletions
+13 -6
View File
@@ -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]]))
+52 -26
View File
@@ -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
+11 -9
View File
@@ -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, "노선 띠 · 측점")
+25 -14
View File
@@ -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<void> {
@@ -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<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (p?.running) void poll();
+5
View File
@@ -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;
@@ -2731,7 +2731,7 @@
"서식": 5
},
"A7": {
"값": "강우 · 포화",
"값": "시나리오 · 포화",
"서식": 4
},
"B7": {
@@ -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` 「위험 측점 없음」 |
+6 -4
View File
@@ -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")
+79 -12
View File
@@ -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))
+17 -7
View File
@@ -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<string, LocaleEntry>;