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:
@@ -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]]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "노선 띠 · 측점")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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` 「위험 측점 없음」 |
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user