Merge origin/sub_laptop_6 — E07 광역 위험 · E06 · E07 단계 · 다국어 · 설정 · E05 설명 충돌 풂(모두 살림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 22:57:46 +09:00
co-authored by Claude Opus 5.5
22 changed files with 2711 additions and 14 deletions
+2 -1
View File
@@ -37,8 +37,9 @@ const FLOWS: readonly GroupFlow[] = [
"DE_Step_Stab3D",
"DE_Step_StabReport",
"DE_Step_StabRock",
"DE_Step_StabArea",
],
icons: ["🪨", "📐", "🧊", "📄", "⛰️"],
icons: ["🪨", "📐", "🧊", "📄", "⛰️", "🗺️"],
},
];
+78
View File
@@ -0,0 +1,78 @@
"""E05 보고서 s7 광역요약 — E07 광역 자연사면 위험 저장본 → 시트 (PLAN 536-2).
칸 자리 = `resources/master_template/report/stab_report_slots.md` s7.
E07 저장본이 없으면 「광역 해석 전」.
"""
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_Router import RESULT, load
CLASS_NAMES = ("", "안정", "보통 안정", "준안정", "위험 하한", "위험 상한", "불안정")
def stamp(project_root: Path) -> list[int] | None:
"""해석 지문용 — 저장본 크기 · 고친 때."""
path = Path(project_root) / RESULT
return [path.stat().st_size, path.stat().st_mtime_ns] if path.is_file() else None
def read(project_root: Path) -> dict[str, Any] | None:
"""저장본에서 보고서에 쓰는 것만(격자 배열 뺌)."""
doc = load(project_root)
if not doc:
return None
return {k: doc[k] for k in ("settings", "computed_at", "stations", "summary")}
def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None:
cell = dict(sheet["칸"].get(a1) or {})
cell["값"] = round(value, 6) if isinstance(value, float) else value
if style is not None:
cell["서식"] = style
sheet["칸"][a1] = cell
def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None:
"""s7 — 조건 넷 · 등급별 면적 여섯 · 위험 측점(22 행부터 한 줄씩)."""
if not area:
_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(
sheet,
"B6",
f"γt {s['gamma_t']:g} · γsat {s['gamma_sat']:g} kN/m³ · c {s['c']:g}~{s['c_hi']:g} kPa · "
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, "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]
template = {col: dict(sheet["칸"].get(f"{col}22") or {}) for col in "ABCDEFG"}
if not rows:
_set(sheet, "A22", "위험 측점 없음")
for i, row in enumerate(rows):
r = 22 + i
for col, cell in template.items():
sheet["칸"][f"{col}{r}"] = dict(cell)
ch = float(row["chainage_m"])
_set(sheet, f"A{r}", row.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}")
_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"F{r}", float(row["risk_area_m2"]))
_set(sheet, f"G{r}", float(row["slope_max_deg"]))
+7 -3
View File
@@ -1,13 +1,13 @@
"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별
`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) ·
`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) · E07 광역 저장본(있으면) ·
E01 복사본(측점 · 횡단 · 노선).
양식 = `resources/master_template/report/stab_report.json`
칸 자리 = 같은 폴더 `stab_report_slots.md`.
몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 ·
프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6)
프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7)
= [보고서 갱신] 때 양식에서 통째로 다시 씀.
판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임
(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음).
@@ -34,6 +34,7 @@ from E03_StabSection import E03_StabSection_View as view2d
from E03_StabSection.E03_StabSection_Engine_Model import final_surface
from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides
from E04_Stab3D import E04_Stab3D_Run as run3d
from E05_StabReport import E05_StabReport_Area as area
from E05_StabReport import E05_StabReport_Figure as figure
ROOT = Path(__file__).resolve().parents[1]
@@ -41,7 +42,7 @@ FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.jso
STAGE = "E05_StabReport"
REPORT, META = "report.json", "report_meta.json"
USER_SHEETS = ("s1", "s2")
PROGRAM_SHEETS = ("s3", "s4", "s5", "s6")
PROGRAM_SHEETS = ("s3", "s4", "s5", "s6", "s7")
BLOCK = 28 # 대표단면 한 쪽 = 28 행
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
BAND_KEY = "fig/3d_band.png"
@@ -168,6 +169,7 @@ def analysis_fingerprint(project_root: Path) -> str:
]
if folder.is_dir()
else [],
"area": area.stamp(project_root),
"form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(),
}
text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str)
@@ -216,6 +218,7 @@ def gather(project_root: Path) -> dict[str, Any]:
"representatives": reps,
"ground": ground,
"sections3d": sorted(sections3d, key=lambda s: s["start_m"]),
"area": area.read(project_root),
"fingerprint": analysis_fingerprint(project_root),
}
@@ -606,6 +609,7 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st
fill_stations(by["s4"], inputs)
jobs = fill_sections(by["s5"], inputs)
jobs.append(fill_3d(by["s6"], inputs))
area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"]))
return jobs
+283
View File
@@ -0,0 +1,283 @@
"""E07 광역 자연사면 위험 — 무한사면 안전율 · SINMAP 안정 지수 (요구 345 · 351 · PLAN 536-2).
순수 계산(storage 모름) — D 예정노선 재해 안전성도 같은 엔진을 씀(한 벌).
근거 = 지식DB `original/예정노선/02` §1.1 · §2 ① · `original/안정성평가/04`(붕적토 범위).
지형 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)
안전율 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)(두 고른 분포 합 · 닫힌 꼴)
등급 1 안정(SI ≥ 1.5) · 2 보통 안정(≥ 1.25) · 3 준안정(≥ 1.0) · 4 위험 하한(≥ 0.5)
· 5 위험 상한(> 0) · 6 불안정(SI = 0) · 0 = 자료 없음
격자 꼴 = 행 0 이 남쪽(y0) · 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) — E04 래스터와 같음.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
import numpy as np
from scipy.interpolate import RegularGridInterpolator
from scipy.spatial import cKDTree
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
compute_receivers,
condition_surface,
)
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)
FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞)
@dataclass(frozen=True)
class AreaParams:
"""한 번 해석 조건 — 단위 kN/m³ · kPa · ° · m · mm/h · m/s."""
gamma_t: float
gamma_sat: float
c: float # 점착력 아래(결정 Fs · SI 범위 아래)
c_hi: float # SI 범위 위
phi: float
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 # 포화 투수계수
# ---------------------------------------------------------------------------
# 지형
# ---------------------------------------------------------------------------
def resample_dem(dem: dict[str, Any], cell_m: float) -> tuple[np.ndarray, float, float]:
"""DTM npz(x · y 오름 축 · z (ny, nx) · valid_mask) → 고른 격자 (z, x0, y0) · 밖 = NaN."""
x = np.asarray(dem["x"], float)
y = np.asarray(dem["y"], float)
z = np.where(np.asarray(dem["valid_mask"], bool), np.asarray(dem["z"], float), np.nan)
nx = int(math.floor((x[-1] - x[0]) / cell_m)) + 1
ny = int(math.floor((y[-1] - y[0]) / cell_m)) + 1
gx = x[0] + np.arange(nx) * cell_m
gy = y[0] + np.arange(ny) * cell_m
yy, xx = np.meshgrid(gy, gx, indexing="ij")
f = RegularGridInterpolator((y, x), z, bounds_error=False, fill_value=np.nan)
return f(np.column_stack([yy.ravel(), xx.ravel()])).reshape(ny, nx), float(x[0]), float(y[0])
def slope_rad(z: np.ndarray, cell_m: float) -> np.ndarray:
"""Horn 3×3 경사(rad) — 이웃에 NaN 이 있거나 격자 끝이면 NaN."""
p = np.pad(z, 1, constant_values=np.nan)
def at(dy: int, dx: int) -> np.ndarray:
return p[1 + dy : 1 + dy + z.shape[0], 1 + dx : 1 + dx + z.shape[1]]
gx = ((at(-1, 1) + 2 * at(0, 1) + at(1, 1)) - (at(-1, -1) + 2 * at(0, -1) + at(1, -1))) / (
8 * cell_m
)
gy = ((at(1, -1) + 2 * at(1, 0) + at(1, 1)) - (at(-1, -1) + 2 * at(-1, 0) + at(-1, 1))) / (
8 * cell_m
)
return np.arctan(np.hypot(gx, gy))
def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray:
"""D8 단위 집수면적 a(m) = 자기 포함 상류 셀 수 × cell. 밖 = NaN.
ponytail: 지형 격자 끝 밖 상류는 모름(복사 DTM = 노선 띠) — 띠 끝 셀은 a 가 작게 나옴 ·
넓은 DEM(D 예정노선 등)이면 그대로 맞음.
"""
filled, valid = condition_surface(z.astype(np.float32))
spec = SimpleNamespace(n_rows=z.shape[0], n_cols=z.shape[1], cell_m=cell_m)
receiver, _ = compute_receivers(spec, filled, valid)
flat = filled.ravel()
order = np.flatnonzero(valid.ravel())
order = order[np.argsort(-flat[order], kind="stable")].tolist() # 높은 데서 낮은 데로
acc = valid.ravel().astype(float).tolist()
recv = receiver.tolist()
for i in order:
r = recv[i]
if r != i:
acc[r] += acc[i]
out = np.asarray(acc).reshape(z.shape) * cell_m
return np.where(valid, out, np.nan)
# ---------------------------------------------------------------------------
# 무한사면 · SINMAP
# ---------------------------------------------------------------------------
def wetness(beta: np.ndarray, a: np.ndarray, p: AreaParams) -> np.ndarray:
"""포화비 m(0~1) — 지표 포화 = 1 · 강우 = min(R·a/(T sinβ), 1)."""
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)
def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]:
"""Fs = (c + c_r)·A + tanφ·B 의 A · B(평평한 셀 = ∞)."""
z = p.soil_depth_m
cos, sin = np.cos(beta), np.sin(beta)
normal = (p.gamma_t * z * (1 - m) + (p.gamma_sat - GAMMA_W) * m * z) * cos**2
shear = (p.gamma_t * z * (1 - m) + p.gamma_sat * m * z) * sin * cos
flat = beta < FLAT_SLOPE_RAD
with np.errstate(divide="ignore", invalid="ignore"):
a = np.where(flat, np.inf, 1.0 / shear)
b = np.where(flat, np.inf, normal / shear)
return a, b
def infinite_slope_fs(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray:
"""결정 안전율(c · φ = 범위 아래 = 보수) — 지식DB 02 §1.1 식."""
a, b = _terms(beta, m, p)
with np.errstate(invalid="ignore"):
return np.where(
np.isinf(a), np.inf, (p.c + p.c_root) * a + math.tan(math.radians(p.phi)) * b
)
def _cdf_sum_uniform(
s: np.ndarray, a1: np.ndarray, a2: np.ndarray, b1: np.ndarray, b2: np.ndarray
) -> np.ndarray:
"""P(X + Y ≤ s) · X ~ U[a1, a2] · Y ~ U[b1, b2] 독립 — 폭 0 이면 그 변수는 상수."""
wa, wb = a2 - a1, b2 - b1
eps = 1e-12
def g(u: np.ndarray) -> np.ndarray:
return np.maximum(u, 0.0) ** 2 / 2
with np.errstate(divide="ignore", invalid="ignore"):
both = (g(s - a1 - b1) - g(s - a2 - b1) - g(s - a1 - b2) + g(s - a2 - b2)) / (wa * wb)
only_a = (s - a1 - b1) / wa
only_b = (s - a1 - b1) / wb
step = (a1 + b1 <= s).astype(float)
out = np.where(
(wa > eps) & (wb > eps), both, np.where(wa > eps, only_a, np.where(wb > eps, only_b, step))
)
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)
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)
si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob))
return np.where(flat, np.inf, si)
def classify(si: np.ndarray) -> np.ndarray:
"""SI → 등급 1~6(0 = 자료 없음 · NaN)."""
cls = np.full(si.shape, 6, dtype=np.uint8)
cls[si > 0] = 5
for k, edge in enumerate(reversed(SI_EDGES)): # 0.5 → 4 · 1.0 → 3 · 1.25 → 2 · 1.5 → 1
cls[si >= edge] = 4 - k
cls[np.isnan(si)] = 0
return cls
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)
fs = infinite_slope_fs(beta, m, p)
si = stability_index(beta, m, p)
nodata = np.isnan(beta)
fs[nodata] = np.nan
si[nodata] = np.nan
m = np.where(nodata, np.nan, m)
return {"slope": beta, "a": a, "m": m, "fs": fs, "si": si, "cls": classify(si)}
# ---------------------------------------------------------------------------
# 노선 띠 · 측점
# ---------------------------------------------------------------------------
def dense_line(line: np.ndarray, step_m: float = 0.5) -> np.ndarray:
"""중심선 [x, y, 누가] 를 step 간격으로 촘촘히(꺾인 점 사이 셀도 바른 누가)."""
out = [line[:1]]
for a, b in zip(line[:-1], line[1:]):
n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / step_m)))
out.append(a + (b - a) * np.linspace(0, 1, n + 1)[1:, None])
return np.vstack(out)
def band_cells(
shape: tuple[int, int], x0: float, y0: float, cell_m: float, line: np.ndarray, half_m: float
) -> tuple[np.ndarray, np.ndarray]:
"""노선 띠(중심선 ± half_m) 셀 → (띠 안, 셀의 가장 가까운 중심선 누가)."""
dense = dense_line(np.asarray(line, float))
yy, xx = np.meshgrid(
y0 + np.arange(shape[0]) * cell_m, x0 + np.arange(shape[1]) * cell_m, indexing="ij"
)
dist, at = cKDTree(dense[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()]))
return (dist <= half_m).reshape(shape), dense[at, 2].reshape(shape)
def station_table(
chainages: list[float],
cell_chain: np.ndarray,
inside: np.ndarray,
out: dict[str, np.ndarray],
cell_m: float,
) -> list[dict[str, Any]]:
"""측점마다 맡은 띠 셀(이웃 측점과 중간 누가로 나눔) —
최소 Fs · SI · 가장 나쁜 등급 · 위험 면적."""
chs = np.asarray(sorted(chainages), float)
if not len(chs):
return []
mids = (chs[:-1] + chs[1:]) / 2
owner = np.searchsorted(mids, cell_chain)
have = inside & (out["cls"] > 0)
rows = []
for k, ch in enumerate(chs):
sel = have & (owner == k)
n = int(sel.sum())
if not n:
rows.append({"chainage_m": float(ch), "cells": 0})
continue
cls = out["cls"][sel]
fs = out["fs"][sel]
si = out["si"][sel]
rows.append(
{
"chainage_m": float(ch),
"cells": n,
"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),
"slope_max_deg": round(float(np.degrees(np.nanmax(out["slope"][sel]))), 1),
}
)
return rows
def class_areas(cls: np.ndarray, inside: np.ndarray, cell_m: float) -> dict[str, float]:
"""띠 안 등급별 면적 m²."""
return {
CLASS_KEYS[k]: round(float(((cls == k) & inside).sum()) * cell_m**2, 1) for k in range(1, 7)
}
+337
View File
@@ -0,0 +1,337 @@
"""E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2).
- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면
`cached` · force = 다시
- `GET …/stab/area/progress` — 진행률
- `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약)
계산 자리 = ③ 서버 단독(엔진 `E07_StabArea_Engine` · 순수). 지형 = 복사본 원지형 DTM
(노선면 합성 안 함 — 노선 위 · 아래 자연사면).
파생 저장본 = `E07_StabArea/_derived/area.json`(지문이 같으면 안 셈).
권한 = main.py `protected_with_company`(지정받은 회사 = GET 만).
격자 꼴(화면 `logic/E07_StabArea_Raster.ts` 와 같은 열쇠) — grid = {x0, y0, dx, nx, ny}
(행 0 = 남쪽) · fs · si = uint16 ×1000(65535 = 자료 없음 · 65.534 위로 자름) ·
cls = uint8 등급(0 없음 · 1~6) · slope = uint16 ×10 ° · m = uint8 ×100(255 없음)
— 모두 줄 우선 · 작은 끝 base64 · 띠 밖 셀 = 자료 없음.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from common_util.common_util_auth import require_company
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model
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" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
RESULT = Path("E07_StabArea") / "_derived" / "area.json"
DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토)
RANGE_C_KPA, RANGE_PHI_DEG = (
5.0,
3.0,
) # SI 범위 위 = 아래 + 이 폭(04 붕적토 범위 c 0~10 · φ 25~30 폭의 반)
_JOBS: dict[str, dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
_IDLE = {"running": False, "percent": 0, "message": None, "error": None}
class AreaRequest(BaseModel):
band_m: float = Field(100.0, ge=10.0, le=500.0) # 노선 띠 = 중심선 ± 이 폭
cell_m: float = Field(2.0, ge=1.0, le=10.0)
material_id: str | None = None # 이름표만(값은 아래 칸)
gamma_t: float = Field(..., gt=0.0, le=30.0)
gamma_sat: float = Field(..., gt=engine.GAMMA_W, le=30.0)
c: float = Field(..., ge=0.0, le=500.0)
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)
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)
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 · φ).")
return self
# ---------------------------------------------------------------------------
# 입력 · 지문 · 저장본
# ---------------------------------------------------------------------------
def _ground(project_root: Path) -> dict[str, Any]:
for path in (ground_model.ground_path(project_root), ground_model.initial_path(project_root)):
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
return ground_model.pick(ground_model.load_defaults())
def defaults(project_root: Path) -> dict[str, Any]:
"""기본 조건 — E02 붕적토(없으면 첫 물성) · 범위 위 = + 5 kPa · + 3°."""
materials = _ground(project_root).get("materials") or []
mat = next((m for m in materials if m.get("id") == DEFAULT_MATERIAL), None) or materials[0]
return {
**{
k: f.default
for k, f in AreaRequest.model_fields.items()
if not f.is_required() and k != "force"
},
"material_id": mat.get("id"),
"gamma_t": float(mat["gamma_t"]),
"gamma_sat": float(mat["gamma_sat"]),
"c": float(mat["c"]),
"c_hi": float(mat["c"]) + RANGE_C_KPA,
"phi": float(mat["phi"]),
"phi_hi": float(mat["phi"]) + RANGE_PHI_DEG,
}
def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str:
body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION}
text = json.dumps(body, ensure_ascii=False, sort_keys=True)
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def load(project_root: Path) -> dict[str, Any] | None:
path = Path(project_root) / RESULT
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def _route_line(root: Path) -> np.ndarray:
points = sorted(
source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"]
)
return np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float)
def _b64(values: np.ndarray, dtype: str) -> str:
return base64.b64encode(np.ascontiguousarray(values, dtype=dtype).tobytes()).decode("ascii")
def _u16(values: np.ndarray, scale: float, keep: np.ndarray) -> str:
v = np.round(np.clip(np.nan_to_num(values * scale, nan=-1.0, posinf=65534.0), -1, 65534))
return _b64(np.where(keep & (v >= 0), v, 65535), "<u2")
def run(
project_root: Path,
root: Path,
settings: dict[str, Any],
finger: str,
report: Callable[[int, str], None],
) -> dict[str, Any]:
"""해석 한 번 → 저장본 쓰고 요약 돌려줌."""
started = time.perf_counter()
cell = float(settings["cell_m"])
report(5, "지형 격자")
with np.load(source.terrain_grid_path(root)) as npz:
dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")}
z, x0, y0 = engine.resample_dem(dem, cell)
report(25, "경사 · 집수면적 · 안전율")
keys = (
"gamma_t",
"gamma_sat",
"c",
"c_hi",
"phi",
"phi_hi",
"c_root",
"soil_depth_m",
"scenario",
"rain_mm_h",
"k_m_s",
)
out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys}))
report(75, "노선 띠 · 측점")
stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"]))
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)
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))
# 띠 상자로 잘라 저장
iy, ix = np.nonzero(inside)
if not len(iy):
raise ValueError("노선 띠가 지형 격자와 겹치지 않습니다.")
win = (slice(iy.min(), iy.max() + 1), slice(ix.min(), ix.max() + 1))
keep = inside[win] & (out["cls"][win] > 0)
areas = engine.class_areas(out["cls"], inside, cell)
have = inside & (out["cls"] > 0)
summary = {
"areas_m2": areas,
"band_area_m2": round(float(inside.sum()) * cell**2, 1),
"covered_area_m2": round(float(have.sum()) * cell**2, 1),
"risk_area_m2": round(
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
],
"stations": len(rows),
}
doc = {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(time.perf_counter() - started, 2),
"grid": {
"x0": x0 + int(ix.min()) * cell,
"y0": y0 + int(iy.min()) * cell,
"dx": cell,
"nx": int(keep.shape[1]),
"ny": int(keep.shape[0]),
},
"fs": _u16(out["fs"][win], 1000, keep),
"si": _u16(out["si"][win], 1000, keep),
"cls": _b64(np.where(keep, out["cls"][win], 0), "u1"),
"slope": _u16(np.degrees(out["slope"][win]), 10, keep),
"m": _b64(np.where(keep, np.round(np.nan_to_num(out["m"][win], nan=0) * 100), 255), "u1"),
"stations": rows,
"summary": summary,
}
report(95, "저장")
atomic_write_json(Path(project_root) / RESULT, doc)
return summary
# ---------------------------------------------------------------------------
# 길
# ---------------------------------------------------------------------------
async def _project_root(project_id: UUID) -> Path:
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT storage_path FROM projects"
" WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL",
(str(project_id),),
)
row = await cursor.fetchone()
if not row or not row["storage_path"]:
raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.")
return Path(resolve_stored_project_path(row["storage_path"]))
def _source_root(project_root: Path) -> Path:
root = source.source_dir(project_root)
if not (root / source.MANIFEST_NAME).is_file():
raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.")
return root
@router.post("/{project_id}/stab/area/analyze", response_model=None)
async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]:
"""[해석] — 지문이 같으면 `cached`(안 셈) · 다르거나 force 면 뒤에서 셈."""
pid = str(project_id)
project_root = await _project_root(project_id)
root = _source_root(project_root)
settings = payload.model_dump(exclude={"force"})
finger = fingerprint(source.manifest(root)["source_fingerprint"], settings)
saved = await asyncio.to_thread(load, project_root)
if not payload.force and saved and saved["fingerprint"] == finger:
return {"status": "cached", "summary": saved["summary"]}
if (_JOBS.get(pid) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 광역 해석이 돌고 있습니다.")
job = {**_IDLE, "running": True, "message": "시작"}
_JOBS[pid] = job
def report(percent: int, message: str) -> None:
job.update(percent=max(0, min(99, int(percent))), message=message)
async def work() -> None:
try:
await asyncio.to_thread(run, project_root, root, settings, finger, report)
job.update(percent=100, message="끝")
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("E07 광역 해석 실패: project_id=%s", pid)
job["error"] = str(error)
finally:
job["running"] = False
task = asyncio.create_task(work(), name=f"E07 area {pid}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted"}
@router.get("/{project_id}/stab/area/progress", response_model=None)
async def progress(project_id: UUID) -> dict[str, Any]:
return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)}
def _overview(project_root: Path, root: Path) -> dict[str, Any]:
saved = load(project_root)
if saved:
current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"])
saved["stale"] = saved["fingerprint"] != current
return {
"defaults": defaults(project_root),
"materials": _ground(project_root).get("materials") or [],
"line": _route_line(root).round(3).tolist(),
"stations": [
{
"chainage_m": s["chainage_m"],
"label": s["label"],
"x": s["center_x"],
"y": s["center_y"],
}
for s in sorted(source.stations(root), key=lambda s: float(s["chainage_m"]))
],
"result": saved,
}
async def _editable(project_id: UUID, session: dict[str, Any]) -> bool:
"""[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만)."""
if session.get("role") == "SYSTEM_ADMIN":
return True
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),))
row = await cursor.fetchone()
return bool(row) and row[0] == session.get("company_id")
@router.get("/{project_id}/stab/area", response_model=None)
async def area(
project_id: UUID, session: dict[str, Any] = Depends(require_company)
) -> dict[str, Any]:
project_root = await _project_root(project_id)
root = _source_root(project_root)
overview = await asyncio.to_thread(_overview, project_root, root)
return {"status": "success", "editable": await _editable(project_id, session), **overview}
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* E07_StabArea_UI_Map.ts
* E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 ·
* 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster";
export type MapLayer = "cls" | "fs";
export interface StationPoint {
chainage_m: number;
label: string | null;
x: number;
y: number;
}
export interface AreaMap {
root: HTMLElement;
setRoute: (line: number[][], stations: StationPoint[]) => void;
setBand: (halfWidthM: number) => void;
setResult: (data: Decoded | null) => void;
setLayer: (layer: MapLayer) => void;
focus: (chainage: number) => void;
dispose: () => void;
}
interface View {
cx: number;
cy: number;
scale: number; // px / m
}
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
export function createAreaMap(): AreaMap {
const root = el("div", { className: "e07-map" });
const canvas = el("canvas", { className: "e07-map__canvas" });
const tip = el("div", { className: "e07-map__tip" });
const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") });
tip.hidden = true;
root.append(canvas, tip, empty);
const ctx = canvas.getContext("2d")!;
let line: number[][] = [];
let stations: StationPoint[] = [];
let band = 100;
let data: Decoded | null = null;
let layer: MapLayer = "cls";
let image: HTMLCanvasElement | null = null;
let picked: number | null = null;
let view: View = { cx: 0, cy: 0, scale: 1 };
let width = 0;
let height = 0;
const toScreen = (x: number, y: number): [number, number] => [
width / 2 + (x - view.cx) * view.scale,
height / 2 - (y - view.cy) * view.scale,
];
const toModel = (px: number, py: number): [number, number] => [
view.cx + (px - width / 2) / view.scale,
view.cy - (py - height / 2) / view.scale,
];
function buildImage(): void {
image = null;
if (!data) return;
const { nx, ny } = data.grid;
const off = document.createElement("canvas");
off.width = nx;
off.height = ny;
off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0);
image = off;
}
function fit(): void {
const xs = line.map((p) => p[0]);
const ys = line.map((p) => p[1]);
if (!xs.length || !width || !height) return;
const pad = band;
const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad];
const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad];
const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92;
view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale };
}
function path(): void {
ctx.beginPath();
line.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
}
function drawRoute(text: string, accent: string): void {
if (line.length < 2) return;
ctx.lineJoin = ctx.lineCap = "round";
// 노선 띠 — 중심선 ± band(옅은 띠)
ctx.globalAlpha = 0.12;
ctx.strokeStyle = accent;
ctx.lineWidth = Math.max(2 * band * view.scale, 1);
path();
ctx.stroke();
ctx.globalAlpha = 1;
ctx.strokeStyle = text;
ctx.lineWidth = 2;
path();
ctx.stroke();
const gap = stations.length > 1 ? 20 * view.scale : 999;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
stations.forEach((s, i) => {
const [px, py] = toScreen(s.x, s.y);
if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return;
const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6;
ctx.fillStyle = on ? accent : text;
ctx.beginPath();
ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9);
});
}
function drawFurniture(text: string): void {
ctx.fillStyle = text;
ctx.strokeStyle = text;
ctx.beginPath();
ctx.moveTo(width - 24, 14);
ctx.lineTo(width - 30, 34);
ctx.lineTo(width - 18, 34);
ctx.closePath();
ctx.fill();
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("N", width - 24, 46);
const target = 100 / view.scale;
const pow = 10 ** Math.floor(Math.log10(target));
const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target;
const px = meters * view.scale;
ctx.fillStyle = css("--color-surface", "#ffffff");
ctx.globalAlpha = 0.85;
ctx.fillRect(width - 24 - px, height - 42, px + 16, 34);
ctx.globalAlpha = 1;
ctx.fillStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(width - 16 - px, height - 16);
ctx.lineTo(width - 16, height - 16);
ctx.stroke();
ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26);
ctx.textAlign = "start";
}
function draw(): void {
const dpr = window.devicePixelRatio || 1;
width = root.clientWidth;
height = root.clientHeight;
if (!width || !height) return;
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const text = css("--color-text", "#1f2937");
const accent = css("--color-primary", "#6d28d9");
if (data && image) {
const g = data.grid;
const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale);
}
drawRoute(text, accent);
drawFurniture(text);
empty.hidden = Boolean(data);
}
let drag: { x: number; y: number; view: View } | null = null;
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const k = Math.exp(-event.deltaY * 0.0015);
view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k };
draw();
});
canvas.addEventListener("pointerdown", (event) => {
drag = { x: event.clientX, y: event.clientY, view: { ...view } };
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointerup", () => (drag = null));
canvas.addEventListener("dblclick", () => {
fit();
draw();
});
canvas.addEventListener("pointerleave", () => (tip.hidden = true));
canvas.addEventListener("pointermove", (event) => {
if (drag) {
view = {
...drag.view,
cx: drag.view.cx - (event.clientX - drag.x) / view.scale,
cy: drag.view.cy + (event.clientY - drag.y) / view.scale,
};
draw();
return;
}
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const i = data ? cellIndex(data.grid, mx, my) : -1;
if (i < 0 || !data!.cls[i]) return void (tip.hidden = true);
const near = stations.reduce<StationPoint | null>(
(best, s) =>
!best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2
? s
: best,
null,
);
const m = data!.m[i];
tip.textContent = [
t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey),
`SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`,
`${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`,
near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
]
.filter(Boolean)
.join("\n");
tip.style.left = `${event.clientX - rect.left + 14}px`;
tip.style.top = `${event.clientY - rect.top + 14}px`;
tip.hidden = false;
});
const resize = new ResizeObserver(() => {
const first = !width;
draw();
if (first) {
fit();
draw();
}
});
resize.observe(root);
const theme = new MutationObserver(draw);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 격자 · 층
const probe = {
project: (x: number, y: number) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = toScreen(x, y);
return { x: rect.left + px, y: rect.top + py };
},
data: () => data,
layer: () => layer,
};
(window as unknown as { __e07Map?: typeof probe }).__e07Map = probe;
return {
root,
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
fit();
draw();
},
setBand(halfWidthM) {
band = halfWidthM;
draw();
},
setResult(next) {
data = next;
buildImage();
draw();
},
setLayer(next) {
layer = next;
buildImage();
draw();
},
focus(chainage) {
picked = chainage;
const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6);
if (s) view = { ...view, cx: s.x, cy: s.y };
draw();
},
dispose() {
resize.disconnect();
theme.disconnect();
if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe)
delete (window as unknown as { __e07Map?: unknown }).__e07Map;
},
};
}
+478
View File
@@ -0,0 +1,478 @@
/* =============================================================================
* E07_StabArea_UI_Page.ts
* E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow).
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 강우(지표 포화 · 강우 강도) · 노선 띠 · [해석] · 진행률
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage.
* ========================================================================== */
import "./E07_StabArea_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
CLASS_COLORS,
CLASS_KEYS,
FS_STEPS,
RISK_CLASS,
decode,
type AreaResult,
} from "./logic/E07_StabArea_Raster";
interface Material {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
}
type Settings = Record<string, number | string | null>;
interface Overview {
editable: boolean;
defaults: Settings;
materials: Material[];
line: number[][];
stations: StationPoint[];
result: AreaResult | null;
}
interface Progress {
running: boolean;
percent: number;
message: string | null;
error: string | null;
}
const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [
["gamma_t", "E07_GammaT", "soil"],
["gamma_sat", "E07_GammaSat", "soil"],
["c", "E07_C", "soil"],
["c_hi", "E07_CHi", "soil"],
["phi", "E07_Phi", "soil"],
["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"],
["band_m", "E07_Band", "band"],
["cell_m", "E07_Cell", "band"],
];
const cacheKey = (projectId: string): string => `e07_settings:${projectId}`;
const shortLabel = (label: string | null, ch: number): string =>
(label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, "");
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
const text = Array.isArray(detail)
? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" · ")
: detail;
throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`);
}
return body;
}
function swatch(color: string): HTMLElement {
const chip = el("i", { className: "e07-chip" });
chip.style.background = color;
return chip;
}
function legend(layer: MapLayer): HTMLElement {
const box = el("div", { className: "e07-legend" });
box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") }));
const rows =
layer === "cls"
? CLASS_KEYS.map((k, i) => ({
color: CLASS_COLORS[i],
label: t(`E07_Class_${k}` as LocaleKey),
}))
: FS_STEPS.map((s, i) => ({
color: s.color,
label:
i === 0
? `< ${s.below.toFixed(1)}`
: s.below === Infinity
? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}`
: `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`,
}));
for (const row of rows) {
const item = el("span", { className: "e07-legend__row" });
item.append(swatch(row.color), el("span", { text: row.label }));
box.append(item);
}
return box;
}
export function renderE07StabArea(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e07-panel" });
const main = el("div", { className: "e07-page" });
mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") }));
// ---- 가운데 ----
const map = createAreaMap();
let layer: MapLayer = "cls";
const legendSlot = el("div", { className: "e07-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
const toggle = el("div", { className: "e07-toggle" });
const layerButtons = (
[
["E07_Layer_Class", "cls"],
["E07_Layer_FS", "fs"],
] as [LocaleKey, MapLayer][]
).map(([key, value], i) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = value;
button.classList.toggle("is-on", i === 0);
button.addEventListener("click", () => {
layerButtons.forEach((b) => b.classList.toggle("is-on", b === button));
layer = value;
map.setLayer(layer);
paintLegend();
});
return button;
});
toggle.append(...layerButtons);
const stage = el("div", { className: "e07-stage" });
stage.append(map.root, legendSlot);
const center = el("div", { className: "e07-center" });
center.append(toggle, stage);
paintLegend();
// ---- 우 ----
const resultBody = el("div", { className: "e07-result" });
const side = createSidePanels({
prefKey: "e07-stab-area-result",
panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }],
defaultOpen: "result",
defaultWidth: 380,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e07-row" });
row.append(center, side.root);
main.append(row);
// ---- 좌 ----
const fields = new Map<string, InputFieldHandle>();
const cards = {
soil: createPanelCard(t("E07_Card_Soil"), false),
rain: createPanelCard(t("E07_Card_Rain"), false),
band: createPanelCard(t("E07_Card_Band"), false),
};
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") },
],
compact: true,
});
cards.soil.append(material.root);
cards.rain.append(scenario.root);
for (const [key, label, card] of NUMBER_FIELDS) {
const field = createInputField({ label: t(label), type: "number", compact: true });
field.input.step = "any";
field.input.dataset.key = key;
fields.set(key, field);
cards[card].append(field.root);
}
const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e07-progress__text" });
const progressBox = el("div", { className: "e07-progress-box" });
progressBox.append(progressBar, progressText);
progressBox.hidden = true;
const defaultsButton = createButton({
label: t("E07_Defaults"),
variant: "ghost",
onClick: () => overview && write(overview.defaults),
});
const analyzeButton = createButton({
label: t("E07_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
});
const reanalyzeButton = createButton({
label: t("E07_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") });
readOnly.hidden = true;
panel.append(
el("p", { className: "e07-hint", text: t("E07_Hint") }),
readOnly,
cards.soil,
cards.rain,
cards.band,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
let overview: Overview | null = null;
let running = false;
function read(): Settings {
const out: Settings = { material_id: material.select.value || null };
out.scenario = scenario.select.value;
for (const [key, field] of fields) out[key] = Number(field.input.value);
return out;
}
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");
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;
map.setBand(Number(fields.get("band_m")!.input.value) || 100);
}
const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read()));
panel.addEventListener("input", remember);
scenario.select.addEventListener("change", () => {
paintScenario();
remember();
});
fields.get("band_m")!.input.addEventListener("input", paintScenario);
material.select.addEventListener("change", () => {
const m = overview?.materials.find((x) => x.id === material.select.value);
if (!m) return;
const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v));
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
set("gamma_t", m.gamma_t);
set("gamma_sat", m.gamma_sat);
set("c", m.c);
set("c_hi", m.c + rangeC);
set("phi", m.phi);
set("phi_hi", m.phi + rangePhi);
remember();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintResult(): void {
const result = overview?.result;
if (!result) {
resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") }));
return;
}
const s = result.summary;
const list = el("dl", { className: "e07-summary" });
const pairs: [LocaleKey, string][] = [
["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_Covered",
`${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`,
],
[
"E07_Sum_Time",
`${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`,
],
];
for (const [key, value] of pairs)
list.append(
el("dt", { text: t(key) }),
el("dd", { text: value, attrs: { "data-field": key } }),
);
const areas = el("table", { className: "e07-table e07-areas" });
const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1;
areas.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Class") }),
el("th", { text: t("E07_Col_Area") }),
el("th", { text: "%" }),
],
}),
...CLASS_KEYS.map((k, i) => {
const name = el("td");
name.append(
swatch(CLASS_COLORS[i]),
el("span", { text: t(`E07_Class_${k}` as LocaleKey) }),
);
return el("tr", {
attrs: { "data-class": k },
children: [
name,
el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }),
el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }),
],
});
}),
);
const table = el("table", { className: "e07-table e07-stations" });
table.append(
el("tr", {
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_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 tr = el("tr", {
className: (r.worst_class ?? 0) >= RISK_CLASS ? "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_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(),
}),
],
});
tr.addEventListener("click", () => {
table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on"));
tr.classList.add("is-on");
map.focus(r.chainage_m);
});
return tr;
}),
);
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") }));
notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") }));
resultBody.replaceChildren(
list,
el("h4", { text: t("E07_Areas_Title") }),
areas,
el("h4", { text: t("E07_Stations_Title") }),
table,
...notes,
);
}
async function refresh(): Promise<void> {
overview = await api<Overview>(`/projects/${projectId}/stab/area`);
material.setOptions(
overview.materials.map((m) => ({ value: m.id, text: m.name })),
String(overview.defaults.material_id ?? ""),
);
map.setResult(overview.result ? decode(overview.result) : null);
paintButtons();
paintResult();
}
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`;
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
else showToast(t("E07_Done"), "success");
await refresh();
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
async function analyze(force: boolean): Promise<void> {
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
method: "POST",
body: JSON.stringify({ ...read(), force }),
});
if (body.status === "cached") {
showToast(t("E07_Cached"), "info");
await refresh();
return;
}
running = true;
paintButtons();
progressBox.hidden = false;
void poll();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
// ---- 처음 ----
paintButtons();
void (async () => {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e07-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
return;
}
const ov = overview!;
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults);
map.setRoute(ov.line, ov.stations);
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (p?.running) void poll();
})();
const leave = new MutationObserver(() => {
if (root.isConnected && main.isConnected) return;
leave.disconnect();
map.dispose();
});
leave.observe(document.body, { childList: true, subtree: true });
}
+231
View File
@@ -0,0 +1,231 @@
/* =============================================================================
* E07_StabArea_UI_Style.css
* E07 광역 위험 지도 — 좌측 조건 · 진행 / 가운데 지도 · 범례 / 우측 요약 · 측점 표
* 색은 테마 토큰만(밝음 · 어두움 같이) · 등급 · 안전율 색만 logic 고정색
* ========================================================================== */
.e07-page {
height: 100dvh;
box-sizing: border-box;
}
.e07-row {
height: 100%;
}
.e07-center {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
min-height: 0;
padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12);
box-sizing: border-box;
}
.e07-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e07-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e07-stage {
position: relative;
flex: 1 1 auto;
min-height: 320px;
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
overflow: hidden;
}
.e07-map {
position: absolute;
inset: 0;
}
.e07-map__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.e07-map__tip {
position: absolute;
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
white-space: pre;
pointer-events: none;
}
.e07-map__empty {
position: absolute;
inset: auto 0 45% 0;
margin: 0;
text-align: center;
color: var(--color-text-muted);
pointer-events: none;
}
.e07-legend-slot {
position: absolute;
left: var(--spacing-8);
bottom: var(--spacing-8);
pointer-events: none;
}
.e07-legend {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
}
.e07-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e07-chip {
display: inline-block;
width: 14px;
height: 10px;
flex: none;
margin-right: var(--spacing-4);
border-radius: 2px;
border: 1px solid var(--color-border);
vertical-align: middle;
}
/* 좌측 */
.e07-hint,
.e07-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
}
.e07-panel .ui-field[hidden] {
display: none;
}
.e07-progress-box {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.e07-progress-box[hidden] {
display: none;
}
.e07-progress {
width: 100%;
}
.e07-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e07-result {
padding: var(--spacing-12);
color: var(--color-text);
}
.e07-result h4 {
margin: var(--spacing-16) 0 var(--spacing-8);
font-size: 13px;
}
.e07-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-8) var(--spacing-12);
margin: 0;
font-size: 13px;
}
.e07-summary dt {
color: var(--color-text-muted);
}
.e07-summary dd {
margin: 0;
font-weight: 600;
text-align: right;
}
.e07-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.e07-table th,
.e07-table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: right;
white-space: nowrap;
}
.e07-table th:first-child,
.e07-table td:first-child,
.e07-stations td:nth-child(4) {
text-align: left;
}
.e07-table th {
color: var(--color-text-muted);
font-weight: 600;
}
.e07-stations tr[data-chainage] {
cursor: pointer;
}
.e07-stations tr[data-chainage]:hover {
background: var(--color-surface-raised);
}
.e07-stations tr.is-risk td {
color: var(--color-danger);
font-weight: 600;
}
.e07-stations tr.is-on {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.e07-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
font-size: 12px;
}
.e07-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
+16
View File
@@ -0,0 +1,16 @@
/* =============================================================================
* E07_StabArea_Route.ts
* 길 선언(PLAN 536-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E07 광역 위험 지도 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import type { DraftRoute } from "../../A00_Common/router";
export const ROUTE: DraftRoute = {
path: ROUTES.E07_STAB_AREA,
workflow: true,
project: true,
footerless: true,
load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea,
};
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* E07_StabArea_Raster.ts
* E07 광역 위험 지도 화면 계산(PLAN 536-2) — 저장본 격자 풀기 · 등급 · 안전율 색 · RGBA · 셀 찾기.
* 격자 꼴 = 서버 `E07_StabArea_Router.py` 머리 주석(fs · si uint16 ×1000 · cls uint8 · slope ×10 ·
* m ×100 · 작은 끝 base64 · 행 0 = 남쪽). 순수 함수만(DOM · fetch 금지).
* ========================================================================== */
export interface Grid {
x0: number;
y0: number;
dx: number;
nx: number;
ny: number;
}
export interface StationRow {
chainage_m: number;
label: string | null;
cells: number;
fs_min?: number;
si_min?: number;
worst_class?: number;
risk_area_m2?: number;
slope_max_deg?: number;
}
export interface AreaSummary {
areas_m2: Record<string, number>;
band_area_m2: number;
covered_area_m2: number;
risk_area_m2: number;
fs_min: number | null;
risk_stations: number[];
stations: number;
}
export interface AreaResult {
stale?: boolean;
computed_at: string;
elapsed_s: number;
settings: Record<string, unknown>;
grid: Grid;
fs: string;
si: string;
cls: string;
slope: string;
m: string;
stations: StationRow[];
summary: AreaSummary;
}
/** 풀어 둔 격자 — 자료 없음 = NaN(fs · si · slope · m) · 0(cls) */
export interface Decoded {
grid: Grid;
fs: Float32Array;
si: Float32Array;
cls: Uint8Array;
slope: Float32Array;
m: Float32Array;
}
/** 등급 열쇠(서버 CLASS_KEYS 차례 · 1~6) */
export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const;
export const RISK_CLASS = 4;
/** 등급 색 1~6 — SINMAP 관례(초록 → 빨강) */
export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"];
/** 안전율 색 단계 — 1.0 · 1.2 · 1.5 경계 */
export const FS_STEPS: readonly { below: number; color: string }[] = [
{ below: 1.0, color: "#a50026" },
{ below: 1.2, color: "#f46d43" },
{ below: 1.5, color: "#fee08b" },
{ below: 2.0, color: "#a6d96a" },
{ below: Infinity, color: "#1a9850" },
];
function bytes(text: string): Uint8Array {
const raw = atob(text);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
return out;
}
function u16(text: string, scale: number): Float32Array {
const b = bytes(text);
const view = new DataView(b.buffer);
const out = new Float32Array(b.length / 2);
for (let i = 0; i < out.length; i += 1) {
const v = view.getUint16(i * 2, true);
out[i] = v === 65535 ? NaN : v / scale;
}
return out;
}
export function decode(result: AreaResult): Decoded {
const m = bytes(result.m);
return {
grid: result.grid,
fs: u16(result.fs, 1000),
si: u16(result.si, 1000),
cls: bytes(result.cls),
slope: u16(result.slope, 10),
m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)),
};
}
function rgb(hex: string): [number, number, number] {
const n = Number.parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function fsColor(value: number): string {
return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color;
}
/** 격자 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 자료 없음 = 투명 */
export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray<ArrayBuffer> {
const { nx, ny } = data.grid;
const out = new Uint8ClampedArray(nx * ny * 4);
const classRgb = CLASS_COLORS.map(rgb);
const fsRgb = FS_STEPS.map((s) => rgb(s.color));
for (let iy = 0; iy < ny; iy += 1) {
const row = ny - 1 - iy;
for (let ix = 0; ix < nx; ix += 1) {
const i = iy * nx + ix;
const k = data.cls[i];
if (!k) continue;
const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below);
const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j];
const at = (row * nx + ix) * 4;
out[at] = color[0];
out[at + 1] = color[1];
out[at + 2] = color[2];
out[at + 3] = 255;
}
}
return out;
}
/** 모델 좌표 → 셀 번호(격자 밖 = -1) */
export function cellIndex(grid: Grid, x: number, y: number): number {
const ix = Math.round((x - grid.x0) / grid.dx);
const iy = Math.round((y - grid.y0) / grid.dx);
return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? -1 : iy * grid.nx + ix;
}
/** 등급별 셀 수(1~6) — 범례 · 검증용 */
export function classCounts(data: Decoded): number[] {
const out = [0, 0, 0, 0, 0, 0];
for (const k of data.cls) if (k) out[k - 1] += 1;
return out;
}
+1
View File
@@ -26,6 +26,7 @@ KIND_STAGE_DIRS = {
"E04_Stab3D",
"E05_StabReport",
"E06_StabRock",
"E07_StabArea",
),
}
+3 -1
View File
@@ -131,6 +131,7 @@ export const ROUTES = {
E04_STAB_3D: "e04-stab-3d",
E05_STAB_REPORT: "e05-stab-report",
E06_STAB_ROCK: "e06-stab-rock",
E07_STAB_AREA: "e07-stab-area",
} as const;
/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */
@@ -140,13 +141,14 @@ export const PLAN_STEP_ROUTES = [
ROUTES.D04_PLAN_CONFIRM,
] as const;
/** E 안정성평가 단계 주소(E02 → E06 · 생성 E01 은 단계 밖) */
/** E 안정성평가 단계 주소(E02 → E06 · 광역 E07 · 생성 E01 은 단계 밖) */
export const STAB_STEP_ROUTES = [
ROUTES.E02_STAB_GROUND,
ROUTES.E03_STAB_SECTION,
ROUTES.E04_STAB_3D,
ROUTES.E05_STAB_REPORT,
ROUTES.E06_STAB_ROCK,
ROUTES.E07_STAB_AREA,
] as const;
export type RouteKey = keyof typeof ROUTES;
+1
View File
@@ -221,6 +221,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset(
"E04_Stab3D",
"E05_StabReport",
"E06_StabRock",
"E07_StabArea",
}
)
+2
View File
@@ -83,6 +83,7 @@ from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register
from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router
from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router
from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router
from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router
from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
@@ -494,6 +495,7 @@ app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
app.include_router(e04_stab_3d_router, dependencies=protected_with_company)
app.include_router(e05_stab_report_router, dependencies=protected_with_company)
app.include_router(e07_stab_area_router, dependencies=protected_with_company)
app.include_router(e03_stab_section_router, dependencies=protected_with_company)
app.include_router(e06_stab_rock_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
@@ -2600,6 +2600,484 @@
}
}
]
},
{
"id": "s7",
"이름": "광역요약",
"칸": {
"A1": {
"값": "광역 자연사면 위험 요약",
"서식": 1
},
"B1": {
"서식": 1
},
"C1": {
"서식": 1
},
"D1": {
"서식": 1
},
"E1": {
"서식": 1
},
"F1": {
"서식": 1
},
"G1": {
"서식": 1
},
"H1": {
"서식": 1
},
"A2": {
"값": "노선 띠 무한사면 · SINMAP 안정 지수 — 노선 위 · 아래 자연사면 표층 붕괴 위험 · 문헌 추정치",
"서식": 2
},
"B2": {
"서식": 2
},
"C2": {
"서식": 2
},
"D2": {
"서식": 2
},
"E2": {
"서식": 2
},
"F2": {
"서식": 2
},
"G2": {
"서식": 2
},
"H2": {
"서식": 2
},
"A4": {
"값": "1. 해석 조건",
"서식": 3
},
"B4": {
"서식": 3
},
"C4": {
"서식": 3
},
"D4": {
"서식": 3
},
"E4": {
"서식": 3
},
"F4": {
"서식": 3
},
"G4": {
"서식": 3
},
"H4": {
"서식": 3
},
"A5": {
"값": "노선 띠",
"서식": 4
},
"B5": {
"서식": 5
},
"C5": {
"서식": 5
},
"D5": {
"서식": 5
},
"E5": {
"서식": 5
},
"F5": {
"서식": 5
},
"G5": {
"서식": 5
},
"H5": {
"서식": 5
},
"A6": {
"값": "토층 물성",
"서식": 4
},
"B6": {
"서식": 5
},
"C6": {
"서식": 5
},
"D6": {
"서식": 5
},
"E6": {
"서식": 5
},
"F6": {
"서식": 5
},
"G6": {
"서식": 5
},
"H6": {
"서식": 5
},
"A7": {
"값": "강우 · 포화",
"서식": 4
},
"B7": {
"서식": 5
},
"C7": {
"서식": 5
},
"D7": {
"서식": 5
},
"E7": {
"서식": 5
},
"F7": {
"서식": 5
},
"G7": {
"서식": 5
},
"H7": {
"서식": 5
},
"A8": {
"값": "해석 때",
"서식": 4
},
"B8": {
"서식": 5
},
"C8": {
"서식": 5
},
"D8": {
"서식": 5
},
"E8": {
"서식": 5
},
"F8": {
"서식": 5
},
"G8": {
"서식": 5
},
"H8": {
"서식": 5
},
"A10": {
"값": "2. 등급별 면적",
"서식": 3
},
"B10": {
"서식": 3
},
"C10": {
"서식": 3
},
"D10": {
"서식": 3
},
"E10": {
"서식": 3
},
"F10": {
"서식": 3
},
"G10": {
"서식": 3
},
"H10": {
"서식": 3
},
"A11": {
"값": "등급",
"서식": 6
},
"B11": {
"값": "안정 지수 SI",
"서식": 6
},
"C11": {
"값": "면적 (m²)",
"서식": 6
},
"D11": {
"값": "비율 (%)",
"서식": 6
},
"A12": {
"값": "안정",
"서식": 7
},
"B12": {
"값": "SI ≥ 1.5",
"서식": 7
},
"C12": {
"서식": 14
},
"D12": {
"식": "IF(SUM(C$12:C$17)>0,C12/SUM(C$12:C$17)*100,\"\")",
"서식": 16
},
"A13": {
"값": "보통 안정",
"서식": 7
},
"B13": {
"값": "1.25 ≤ SI < 1.5",
"서식": 7
},
"C13": {
"서식": 14
},
"D13": {
"식": "IF(SUM(C$12:C$17)>0,C13/SUM(C$12:C$17)*100,\"\")",
"서식": 16
},
"A14": {
"값": "준안정",
"서식": 7
},
"B14": {
"값": "1.0 ≤ SI < 1.25",
"서식": 7
},
"C14": {
"서식": 14
},
"D14": {
"식": "IF(SUM(C$12:C$17)>0,C14/SUM(C$12:C$17)*100,\"\")",
"서식": 16
},
"A15": {
"값": "위험 하한",
"서식": 7
},
"B15": {
"값": "0.5 ≤ SI < 1.0",
"서식": 7
},
"C15": {
"서식": 14
},
"D15": {
"식": "IF(SUM(C$12:C$17)>0,C15/SUM(C$12:C$17)*100,\"\")",
"서식": 16
},
"A16": {
"값": "위험 상한",
"서식": 7
},
"B16": {
"값": "0 < SI < 0.5",
"서식": 7
},
"C16": {
"서식": 14
},
"D16": {
"식": "IF(SUM(C$12:C$17)>0,C16/SUM(C$12:C$17)*100,\"\")",
"서식": 16
},
"A17": {
"값": "불안정",
"서식": 7
},
"B17": {
"값": "SI = 0",
"서식": 7
},
"C17": {
"서식": 14
},
"D17": {
"식": "IF(SUM(C$12:C$17)>0,C17/SUM(C$12:C$17)*100,\"\")",
"서식": 16
},
"A18": {
"값": "합계",
"서식": 4
},
"B18": {
"서식": 4
},
"C18": {
"식": "SUM(C12:C17)",
"서식": 14
},
"D18": {
"서식": 16
},
"A20": {
"값": "3. 위험 측점 (SI < 1 셀이 있는 측점)",
"서식": 3
},
"B20": {
"서식": 3
},
"C20": {
"서식": 3
},
"D20": {
"서식": 3
},
"E20": {
"서식": 3
},
"F20": {
"서식": 3
},
"G20": {
"서식": 3
},
"H20": {
"서식": 3
},
"A21": {
"값": "측점",
"서식": 6
},
"B21": {
"값": "누가거리 (m)",
"서식": 6
},
"C21": {
"값": "최소 Fs",
"서식": 6
},
"D21": {
"값": "최소 SI",
"서식": 6
},
"E21": {
"값": "가장 나쁜 등급",
"서식": 6
},
"F21": {
"값": "위험 면적 (m²)",
"서식": 6
},
"G21": {
"값": "최대 경사 (°)",
"서식": 6
},
"A22": {
"서식": 7
},
"B22": {
"서식": 17
},
"C22": {
"서식": 8
},
"D22": {
"서식": 8
},
"E22": {
"서식": 7
},
"F22": {
"서식": 14
},
"G22": {
"서식": 16
}
},
"병합": [
"A1:H1",
"A2:H2",
"A4:H4",
"B5:H5",
"B6:H6",
"B7:H7",
"B8:H8",
"A10:H10",
"A20:H20"
],
"열": {
"A": {
"폭": 14
},
"B": {
"폭": 15
},
"C": {
"폭": 11
},
"D": {
"폭": 11
},
"E": {
"폭": 13
},
"F": {
"폭": 13
},
"G": {
"폭": 12
},
"H": {
"폭": 6
}
},
"행": {
"1": {
"높이": 36
},
"2": {
"높이": 20
},
"4": {
"높이": 22
},
"10": {
"높이": 22
},
"20": {
"높이": 22
},
"21": {
"높이": 32
}
},
"보기": {
"눈금선": false
},
"인쇄": {
"용지": "A4",
"방향": "portrait",
"맞춤": {
"폭": 1,
"높이": 0
},
"여백": {
"위": 15,
"아래": 15,
"왼": 10,
"오른": 10
},
"머리행": [
21,
21
]
}
}
],
"활성": "s1"
@@ -79,3 +79,13 @@
| 20 행부터 구간 하나 = 한 줄 | A 구간 · B 시작 측점 · C 끝 측점(서식 7) · D 3D 최소 Fs · E 2D 최소 Fs(서식 8) · **F 3D/2D 비 = 수식 `IF(E{r}>0,D{r}/E{r},"")`**(서식 8) · G 임계 체적 m³(서식 14) · H 판정(서식 7 → N.G 10 · O.K 11) |
- 20 행 서식을 줄마다 복사.
## s7 광역요약 (프로그램 몫 · 머리 줄 21 행 반복 · E07 광역 저장본 · 없으면 `B5` 에 「광역 해석 전」)
| 칸 | 내용 |
| --- | --- |
| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 강우 · 포화 · 해석 때 글 |
| `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` 「위험 측점 없음」 |
- 22 행 서식을 줄마다 복사. 위험 측점 = 맡은 띠 셀에 SI < 1(등급 4 이상)이 있는 측점.
+77 -4
View File
@@ -221,6 +221,79 @@ def test_처음_만듦_측점수_NG_그림(project):
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": "saturated",
"rain_mm_h": 30.0,
"k_m_s": 1e-4,
},
"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("지표")
)
vals = book["계산값"]["s7"]
assert vals["C18"] == {"수": "176"} and vals["D17"]["수"].startswith("20.45")
def test_갱신_사용자몫_불변_덮임(project):
_create(project)
book, _ = store.load(project)
@@ -278,7 +351,7 @@ def test_xlsx_되읽기_pdf_쪽수(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 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(
@@ -294,8 +367,8 @@ def test_xlsx_되읽기_pdf_쪽수(project):
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
assert len(texts) == 3 + len(stab) + len(REPS) + 1
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1
assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1
def test_단면_그림_SVG():
@@ -333,7 +406,7 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
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"]["시트"]) == 6
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
@@ -36,7 +36,7 @@ STYLE_KEYS = {
} # fmt: skip
PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"}
A1 = re.compile(r"^([A-Z]{1,3})(\d+)$")
NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"]
NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"]
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -148,7 +148,7 @@ def test_양식_계약():
book = _form()
_contract(book)
assert [s["이름"] for s in book["시트"]] == NAMES
assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6"]
assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7"]
def test_양식_인쇄_쪽나눔_머리행_그림():
@@ -159,7 +159,15 @@ def test_양식_인쇄_쪽나눔_머리행_그림():
assert by["측점표"]["인쇄"]["방향"] == "landscape" and by["측점표"]["인쇄"]["머리행"] == [4, 5]
assert by["대표단면"]["인쇄"]["쪽나눔"] == [BLOCK]
figs = {n: len(s.get("그림", [])) for n, s in by.items()}
assert figs == {"표지": 0, "개요": 0, "입력조건": 0, "측점표": 0, "대표단면": 1, "3D요약": 1}
assert figs == {
"표지": 0,
"개요": 0,
"입력조건": 0,
"측점표": 0,
"대표단면": 1,
"3D요약": 1,
"광역요약": 0,
}
# N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀)
assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9
assert any(st.get("글자색") == "#C00000" and st.get("채움") for st in _form()["서식"])
@@ -222,8 +230,8 @@ def test_견본_pdf_쪽수_머리행(png):
)
sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t]
assert len(sections) == 2 # 단면마다 한 쪽
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1
assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1
assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1
assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab)
html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0]
assert html.count("<img ") == 3 # 단면 2 + 3D 띠 1
+158
View File
@@ -0,0 +1,158 @@
"""536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`.
무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) ·
D8 집수면적 견본(기운 평면 · V 골짜기) · 강우 포화비 · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문.
"""
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)
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)
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)
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_강우_포화비():
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 _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):
settings = {**router_mod.defaults(project), "band_m": 30.0, "cell_m": 2.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"], "<u2", g)
# 붕적토 포화 30° — 손계산 Fs
b = math.radians(30)
n = (18.5 - 9.81) * 1.5 * math.cos(b) ** 2
hand = (5 + n * math.tan(math.radians(27))) / (18.5 * 1.5 * math.sin(b) * math.cos(b))
inner = fs[cls > 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
+3
View File
@@ -16,6 +16,7 @@
* - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11)
* - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기
* - ui_template_locale_e04.ts : E04 안정성평가 3D 해석
* - ui_template_locale_e07.ts : E07 안정성평가 광역 위험 지도
*
* 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다.
* ========================================================================== */
@@ -36,6 +37,7 @@ import { ui_locales_de } from "./ui_template_locale_de";
import { ui_locales_e } from "./ui_template_locale_e";
import { ui_locales_e05 } from "./ui_template_locale_e05";
import { ui_locales_e06 } from "./ui_template_locale_e06";
import { ui_locales_e07 } from "./ui_template_locale_e07";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -79,6 +81,7 @@ export const ui_locales = {
...ui_locales_e,
...ui_locales_e05,
...ui_locales_e06,
...ui_locales_e07,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+1
View File
@@ -18,6 +18,7 @@ export const ui_locales_de = {
DE_Step_Stab3D: ["3D 해석", "3D Analysis"],
DE_Step_StabReport: ["보고서", "Report"],
DE_Step_StabRock: ["암반 해석", "Rock Analysis"],
DE_Step_StabArea: ["광역 위험", "Area Hazard"],
DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."],
/* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */
+83
View File
@@ -0,0 +1,83 @@
/* =============================================================================
* ui_template_locale_e07.ts
* 다국어 사전 — E07 안정성평가 광역 자연사면 위험 지도(요구 345 · 351 · PLAN 536-2)
*
* 신규 문구는 해당 섹션 최하단에 추가.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e07 = {
/* --- E07 광역 위험 지도 --- */
E07_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
E07_Hint: [
"노선 띠 안 자연사면의 표층 붕괴 위험 — 무한사면 안전율과 SINMAP 안정 지수(SI)로 등급을 매깁니다. 지형 = 복사한 원지형.",
"Shallow-landslide hazard of natural slopes in the route band — infinite-slope Fs and SINMAP stability index (SI). Terrain = copied original DTM.",
],
E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E07_Card_Soil: ["토층 물성", "Soil layer"],
E07_Card_Rain: ["강우 · 포화", "Rain · saturation"],
E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"],
E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"],
E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"],
E07_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"],
E07_C: ["c 아래 (kPa)", "c low (kPa)"],
E07_CHi: ["c 위 (kPa)", "c high (kPa)"],
E07_Phi: ["φ 아래 (°)", "φ low (°)"],
E07_PhiHi: ["φ 위 (°)", "φ high (°)"],
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_Band: ["띠 폭 ± (m)", "Band half-width (m)"],
E07_Cell: ["격자 (m)", "Cell (m)"],
E07_Defaults: ["기본값", "Defaults"],
E07_Analyze: ["해석", "Analyze"],
E07_Reanalyze: ["다시 해석", "Re-analyze"],
E07_Running: ["해석 중", "Analyzing"],
E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."],
E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."],
E07_Layer_Class: ["등급 (SI)", "Class (SI)"],
E07_Layer_FS: ["안전율 Fs", "Safety factor Fs"],
E07_Legend_Class: ["SINMAP 등급", "SINMAP class"],
E07_Legend_FS: ["무한사면 Fs", "Infinite-slope Fs"],
E07_Class_stable: ["안정 (SI ≥ 1.5)", "Stable (SI ≥ 1.5)"],
E07_Class_moderate: ["보통 안정 (≥ 1.25)", "Moderately stable (≥ 1.25)"],
E07_Class_quasi: ["준안정 (≥ 1.0)", "Quasi-stable (≥ 1.0)"],
E07_Class_lower: ["위험 하한 (≥ 0.5)", "Lower threshold (≥ 0.5)"],
E07_Class_upper: ["위험 상한 (> 0)", "Upper threshold (> 0)"],
E07_Class_defended: ["불안정 (SI = 0)", "Defended (SI = 0)"],
E07_Map_Empty: [
"[해석] 을 누르면 위험 지도가 나옵니다.",
"Press [Analyze] to draw the hazard map.",
],
E07_Tip_Slope: ["경사", "Slope"],
E07_Tip_Station: ["가까운 측점", "Nearest station"],
E07_Result_Title: ["광역 결과", "Area result"],
E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."],
E07_Result_Stale: [
"엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].",
"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.",
],
E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"],
E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"],
E07_Sum_RiskStations: ["위험 측점", "Hazard stations"],
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_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_RiskArea: ["위험 m²", "Hazard m²"],
} as const satisfies Record<string, LocaleEntry>;