Merge origin/sub_laptop_6 — E07 광역 위험 · E06 · E07 단계 · 다국어 · 설정 · E05 설명 충돌 풂(모두 살림)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,8 +37,9 @@ const FLOWS: readonly GroupFlow[] = [
|
||||
"DE_Step_Stab3D",
|
||||
"DE_Step_StabReport",
|
||||
"DE_Step_StabRock",
|
||||
"DE_Step_StabArea",
|
||||
],
|
||||
icons: ["🪨", "📐", "🧊", "📄", "⛰️"],
|
||||
icons: ["🪨", "📐", "🧊", "📄", "⛰️", "🗺️"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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"]))
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ KIND_STAGE_DIRS = {
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
"E06_StabRock",
|
||||
"E07_StabArea",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -221,6 +221,7 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset(
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
"E06_StabRock",
|
||||
"E07_StabArea",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 이상)이 있는 측점.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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) --- */
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user