Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -31,8 +31,15 @@ const FLOWS: readonly GroupFlow[] = [
|
||||
},
|
||||
{
|
||||
routes: STAB_STEP_ROUTES,
|
||||
labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"],
|
||||
icons: ["🪨", "📐", "🧊", "📄"],
|
||||
labels: [
|
||||
"DE_Step_StabGround",
|
||||
"DE_Step_StabSection",
|
||||
"DE_Step_Stab3D",
|
||||
"DE_Step_StabReport",
|
||||
"DE_Step_StabRock",
|
||||
"DE_Step_StabArea",
|
||||
],
|
||||
icons: ["🪨", "📐", "🧊", "📄", "⛰️", "🗺️"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]:
|
||||
for item in saved:
|
||||
current = run.fingerprint(finger_source, ground, item.pop("settings"))
|
||||
item["stale"] = item.pop("fingerprint") != current
|
||||
points = source.db_snapshot(root).get("route_points") or []
|
||||
return {
|
||||
"sections": run.auto_sections(stations),
|
||||
"stations": [
|
||||
@@ -196,9 +195,10 @@ def _overview(project_root: Path, root: Path) -> dict[str, Any]:
|
||||
}
|
||||
for s in stations
|
||||
],
|
||||
# 중심선 = 측점 중심(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름)
|
||||
"line": [
|
||||
[float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])]
|
||||
for p in sorted(points, key=lambda p: p["sequence_num"])
|
||||
[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])]
|
||||
for s in sorted(stations, key=lambda s: float(s["chainage_m"]))
|
||||
],
|
||||
"saved": saved,
|
||||
"defaults": SearchSettings().model_dump(),
|
||||
|
||||
@@ -33,7 +33,7 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA
|
||||
from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine
|
||||
from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine
|
||||
|
||||
ENGINE_VERSION = "e04-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
|
||||
ENGINE_VERSION = "e04-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
|
||||
STAGE_DIR = "E04_Stab3D"
|
||||
GROUND_FILE = Path("E02_StabGround") / "ground.json"
|
||||
SECTION_LENGTH_M = 100.0 # 자동 구간 길이
|
||||
@@ -127,13 +127,19 @@ def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _node_mask(
|
||||
root: Path, grid: dict[str, Any], start_m: float, end_m: float, half_width_m: float
|
||||
stations: list[dict[str, Any]],
|
||||
grid: dict[str, Any],
|
||||
start_m: float,
|
||||
end_m: float,
|
||||
half_width_m: float,
|
||||
) -> np.ndarray:
|
||||
"""탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안."""
|
||||
points = sorted(
|
||||
source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"]
|
||||
"""탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안
|
||||
· 가장 가까운 점이 중심선 양 끝점인 셀(시점 앞 · 종점 뒤)은 뺌.
|
||||
|
||||
중심선 = 측점 중심을 이은 선(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름)."""
|
||||
line = np.array(
|
||||
[[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] for s in stations]
|
||||
)
|
||||
line = np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float)
|
||||
# 0.5 m 로 촘촘히 — 꺾인 점 사이 셀도 바른 누가
|
||||
dense = [line[:1]]
|
||||
for a, b in zip(line[:-1], line[1:]):
|
||||
@@ -145,6 +151,7 @@ def _node_mask(
|
||||
dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()]))
|
||||
chain = line[at, 2]
|
||||
keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6)
|
||||
keep &= (at > 0) & (at < len(line) - 1)
|
||||
return keep.reshape(xx.shape)
|
||||
|
||||
|
||||
@@ -197,7 +204,7 @@ def run_engine(
|
||||
strata = grid_engine.strata_from_grid(
|
||||
grid, (ground or {}).get("materials"), water_depth_m=0.0 if wet else None
|
||||
)
|
||||
mask = _node_mask(root, grid, settings["start_m"], settings["end_m"], half)
|
||||
mask = _node_mask(stations, grid, settings["start_m"], settings["end_m"], half)
|
||||
ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"]))
|
||||
params = search_engine.SearchParams(
|
||||
node_mask=mask,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { cutCells } from "./logic/E04_Stab3D_Mesh";
|
||||
import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh";
|
||||
import {
|
||||
FS_STEPS,
|
||||
VOLUME_STEPS,
|
||||
@@ -40,6 +40,8 @@ interface View {
|
||||
scale: number; // px / m
|
||||
}
|
||||
|
||||
const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음
|
||||
|
||||
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
|
||||
|
||||
const css = (name: string, fallback: string): string =>
|
||||
@@ -61,6 +63,7 @@ export function createPlanMap(): PlanMap {
|
||||
let layer: MapLayer = "fs";
|
||||
let image: HTMLCanvasElement | null = null;
|
||||
let outline: Set<number> = new Set();
|
||||
let marker: { x: number; y: number } | null = null;
|
||||
let view: View = { cx: 0, cy: 0, scale: 1 };
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
@@ -118,7 +121,7 @@ export function createPlanMap(): PlanMap {
|
||||
if (line.length < 2) return;
|
||||
ctx.lineJoin = ctx.lineCap = "round";
|
||||
ctx.strokeStyle = text;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
line.forEach((p, i) =>
|
||||
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
|
||||
@@ -184,9 +187,9 @@ export function createPlanMap(): PlanMap {
|
||||
}
|
||||
|
||||
function drawMarker(text: string): void {
|
||||
if (!result) return;
|
||||
if (!result || !marker) return;
|
||||
const { best } = result;
|
||||
const [px, py] = toScreen(best.x, best.y);
|
||||
const [px, py] = toScreen(marker.x, marker.y);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
@@ -245,7 +248,7 @@ export function createPlanMap(): PlanMap {
|
||||
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");
|
||||
const accent = RANGE_COLOR;
|
||||
if (result && image) {
|
||||
const g = result.grid;
|
||||
const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx);
|
||||
@@ -335,6 +338,8 @@ export function createPlanMap(): PlanMap {
|
||||
return { x: rect.left + px, y: rect.top + py };
|
||||
},
|
||||
result: () => result,
|
||||
marker: () => marker,
|
||||
outline: () => [...outline],
|
||||
layer: () => layer,
|
||||
};
|
||||
(window as unknown as { __e04Map?: typeof probe }).__e04Map = probe;
|
||||
@@ -354,7 +359,9 @@ export function createPlanMap(): PlanMap {
|
||||
},
|
||||
setResult(next) {
|
||||
result = next;
|
||||
outline = new Set(next ? cutCells(next.grid, next.z, next.best).keys() : []);
|
||||
const cut = next ? cutCells(next.grid, next.z, next.best) : new Map<number, number>();
|
||||
outline = new Set(cut.keys());
|
||||
marker = next ? footprintCenter(next.grid, cut, next.best) : null;
|
||||
buildImage();
|
||||
fit();
|
||||
draw();
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
.e04-map__empty {
|
||||
position: absolute;
|
||||
inset: auto 0 45% 0;
|
||||
inset: var(--spacing-16) 0 auto 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -90,6 +90,22 @@ export function cutCells(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 임계 활동체 평면 중심 = 잘린 셀 중심의 평균(없으면 구 중심) — 지도 최소 표시 자리 */
|
||||
export function footprintCenter(
|
||||
grid: Grid,
|
||||
cut: ReadonlyMap<number, number>,
|
||||
best: SlipBest,
|
||||
): { x: number; y: number } {
|
||||
if (!cut.size) return { x: best.x, y: best.y };
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (const index of cut.keys()) {
|
||||
sx += grid.x0 + (index % grid.nx) * grid.dx;
|
||||
sy += grid.y0 + Math.floor(index / grid.nx) * grid.dx;
|
||||
}
|
||||
return { x: sx / cut.size, y: sy / cut.size };
|
||||
}
|
||||
|
||||
/** 잘린 셀의 8 이웃 중 잘리지 않은 셀(값 있음) — 활동면 테두리 */
|
||||
export function rimCells(
|
||||
grid: Grid,
|
||||
|
||||
@@ -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,12 +1,13 @@
|
||||
"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
|
||||
|
||||
입력 = E02 지반 조건(`ground.json`) · E03 2D 결과 요약(`_derived/result2d.json`) · 측점별 저장본 ·
|
||||
대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E01 복사본(측점 · 횡단 · 노선).
|
||||
입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별
|
||||
`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 는 조건부 서식을 안 먹음).
|
||||
@@ -26,10 +27,14 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_derived_store import read_derived
|
||||
from E01_StabRegister import E01_StabRegister_Source as source
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model
|
||||
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]
|
||||
@@ -37,14 +42,11 @@ 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 행
|
||||
E03 = Path("E03_StabSection")
|
||||
RESULT2D = E03 / "_derived" / "result2d.json"
|
||||
STATION_RESULTS = E03 / "_derived" / "results"
|
||||
REPRESENTATIVE = E03 / "representative.json"
|
||||
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
|
||||
BAND_KEY = "fig/3d_band.png"
|
||||
LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N
|
||||
LOWEST_N = view2d.REPRESENTATIVE_LOWEST
|
||||
CASES = ("dry", "wet", "seismic")
|
||||
SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
|
||||
METHOD_NAMES = {
|
||||
@@ -55,9 +57,9 @@ METHOD_NAMES = {
|
||||
"spencer": "Spencer",
|
||||
}
|
||||
REASONS = {
|
||||
"fill_10m": "쌓기 높이 10 m 이상",
|
||||
"cut_15m": "깎기 높이 15 m 이상",
|
||||
"lowest_fs": "최소 안전율 하위",
|
||||
"fill_height": "쌓기 높이 10 m 이상",
|
||||
"cut_height": "깎기 높이 15 m 이상",
|
||||
"lowest": "최소 안전율 하위",
|
||||
"user": "사용자 지정",
|
||||
}
|
||||
GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"}
|
||||
@@ -104,23 +106,63 @@ def _ground(project_root: Path) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _station_result(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||
folder = Path(project_root) / STATION_RESULTS
|
||||
for name in (key, re.sub(r"[^0-9A-Za-z._+-]", "_", key)):
|
||||
found = _read(folder / f"{name}.json")
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None:
|
||||
saved = read_derived(Path(project_root), KIND_STATION, fp)
|
||||
return (saved or {}).get("value", {}).get("out")
|
||||
|
||||
|
||||
def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]:
|
||||
"""E03 요약 줄 → 보고서 줄 — 비탈 = 기준 대비 가장 낮은 쪽(fs / 기준) · 판정 = 조건 하나라도
|
||||
N.G 면 ng."""
|
||||
roles = {s["side"]: s["role"] for s in row.get("sides") or []}
|
||||
cases = row.get("cases") or {}
|
||||
worst = min(
|
||||
(
|
||||
(c["fs"] / c["required"], side)
|
||||
for sides in cases.values()
|
||||
for side, c in sides.items()
|
||||
if c.get("fs") is not None and c.get("required")
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
side = worst[1] if worst else None
|
||||
verdicts = set((row.get("verdict") or {}).values())
|
||||
values = [v for v in (row.get("min") or {}).values() if v is not None]
|
||||
return {
|
||||
"station_key": row["key"],
|
||||
"chainage_m": row["chainage_m"],
|
||||
"fp": row.get("fp"),
|
||||
"side": roles.get(side),
|
||||
"fs_by_condition": {
|
||||
cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides
|
||||
},
|
||||
"fs_min": min(values) if values else None,
|
||||
"min_by_condition": row.get("min") or {},
|
||||
"method": method,
|
||||
"verdict": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None,
|
||||
"representative": row.get("representative") or {},
|
||||
}
|
||||
|
||||
|
||||
def _result2d(project_root: Path) -> dict[str, Any] | None:
|
||||
"""E03 가장 최근 요약(지금 입력과 달라도 · E03 화면과 같은 판) + 대표 단면 표시."""
|
||||
summary, _ = _latest_summary(project_root, "")
|
||||
if not summary:
|
||||
return None
|
||||
rows = [dict(r) for r in summary.get("stations") or []]
|
||||
view2d.mark_representative(rows, _overrides(project_root))
|
||||
method = summary.get("method") or view2d.PRIMARY
|
||||
return {**summary, "stations": [_row2d(r, method) for r in rows]}
|
||||
|
||||
|
||||
def analysis_fingerprint(project_root: Path) -> str:
|
||||
"""해석 지문 — 2D 요약 지문 · 대표 단면 · 지반 조건 · 3D 저장본 · 양식이 같으면 같음
|
||||
(가벼운 읽기만)."""
|
||||
project_root = Path(project_root)
|
||||
r2 = _read(project_root / RESULT2D)
|
||||
r2, _ = _latest_summary(project_root, "")
|
||||
folder = run3d.results_dir(project_root)
|
||||
body = {
|
||||
"2d": [r2.get("input_fingerprint"), r2.get("computed_at")] if r2 else None,
|
||||
"2d": [r2.get("fingerprint"), r2.get("computed_at")] if r2 else None,
|
||||
"rep": _read(project_root / REPRESENTATIVE),
|
||||
"ground": _ground(project_root),
|
||||
"3d": [
|
||||
@@ -128,42 +170,38 @@ 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)
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _representatives(r2: dict[str, Any], rep: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
by_key = {s["station_key"]: s for s in r2.get("stations") or []}
|
||||
if rep and isinstance(rep.get("stations"), list):
|
||||
picked = [s for s in rep["stations"] if s.get("enabled", True)]
|
||||
else:
|
||||
ranked = sorted(
|
||||
(s for s in by_key.values() if s.get("fs_min") is not None), key=lambda s: s["fs_min"]
|
||||
)
|
||||
picked = [{"station_key": s["station_key"], "reason": "lowest_fs"} for s in ranked][
|
||||
:LOWEST_N
|
||||
]
|
||||
return [
|
||||
{**p, "summary": by_key[p["station_key"]]} for p in picked if p["station_key"] in by_key
|
||||
]
|
||||
def _representatives(r2: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""E03 대표 단면 켬 — 까닭 = 사용자 켬이면 user · 아니면 자동 첫 까닭."""
|
||||
out = []
|
||||
for s in r2.get("stations") or []:
|
||||
mark = s["representative"]
|
||||
if mark.get("on"):
|
||||
reason = "user" if mark.get("user") else (mark.get("auto") or ["user"])[0]
|
||||
out.append({"station_key": s["station_key"], "reason": reason, "summary": s})
|
||||
return out
|
||||
|
||||
|
||||
def gather(project_root: Path) -> dict[str, Any]:
|
||||
"""빌더 입력 한 벌 — 2D 결과가 없으면 `MissingInput`."""
|
||||
project_root = Path(project_root)
|
||||
r2 = _read(project_root / RESULT2D)
|
||||
r2 = _result2d(project_root)
|
||||
if not r2 or not r2.get("stations"):
|
||||
raise MissingInput("2D 해석 결과가 없습니다 — E03 에서 [해석] 뒤 만드세요.")
|
||||
root = source.source_dir(project_root)
|
||||
db = source.db_snapshot(root)
|
||||
ground = _ground(project_root)
|
||||
resolved = {round(float(s["chainage_m"]), 3): s for s in ground_model.resolve_stations(ground)}
|
||||
reps = _representatives(r2, _read(project_root / REPRESENTATIVE))
|
||||
reps = _representatives(r2)
|
||||
for item in reps:
|
||||
chainage = float(item["summary"]["chainage_m"])
|
||||
item["result"] = _station_result(project_root, item["station_key"])
|
||||
item["result"] = _station_result(project_root, item["summary"]["fp"])
|
||||
try:
|
||||
item["cross"] = source.cross_section(root, chainage)
|
||||
except KeyError:
|
||||
@@ -181,6 +219,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),
|
||||
}
|
||||
|
||||
@@ -304,14 +343,19 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
|
||||
_set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0))
|
||||
defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]}
|
||||
names = {m["id"]: m["name"] for m in ground.get("materials") or []}
|
||||
rows: list[tuple[dict[str, Any], str]] = [(m, "전체") for m in ground.get("materials") or []]
|
||||
mapped = set((ground.get("label_map") or {}).values()) # 지반 라벨에 이은 지층만 「전체」
|
||||
rows: list[tuple[dict[str, Any], str]] = [
|
||||
(m, "전체" if m["id"] in mapped else "-") for m in ground.get("materials") or []
|
||||
]
|
||||
for zone in ground.get("zones") or []:
|
||||
span = f"{float(zone['from_m']):g} ~ {float(zone['to_m']):g} m"
|
||||
for layer, where in (("soil", "위"), ("rock", "아래")):
|
||||
mid = zone.get(layer)
|
||||
match = next((m for m in ground["materials"] if m["id"] == mid), None)
|
||||
if match:
|
||||
rows.append((match, f"{span} {where} 지층"))
|
||||
depth = zone.get("soil_thickness_m") if layer == "soil" else None
|
||||
more = f" · 두께 {float(depth):g} m" if depth is not None else ""
|
||||
rows.append((match, f"{span} {where} 지층{more}"))
|
||||
if zone.get("soil_thickness_m") is not None and not (zone.get("soil") or zone.get("rock")):
|
||||
rows.append(({"name": "토층 두께"}, f"{span} · {zone['soil_thickness_m']:g} m"))
|
||||
for i, (m, span) in enumerate(rows):
|
||||
@@ -426,7 +470,7 @@ def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[s
|
||||
f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m",
|
||||
)
|
||||
reason = REASONS.get(rep.get("reason"), rep.get("reason") or "")
|
||||
if rep.get("reason") == "lowest_fs":
|
||||
if rep.get("reason") == "lowest":
|
||||
reason = f"{reason} {LOWEST_N}"
|
||||
_set(sheet, f"B{2 + dr}", reason)
|
||||
key = fig_key(rep["station_key"])
|
||||
@@ -513,19 +557,23 @@ def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]:
|
||||
ground = inputs["ground"]
|
||||
labels = _labels(inputs["stations"])
|
||||
rows2d = inputs["result2d"]["stations"]
|
||||
crit = _criterion(ground, "dry")
|
||||
sections = []
|
||||
for i, sec in enumerate(inputs["sections3d"]):
|
||||
r = 20 + i
|
||||
_copy_row(sheet, 20, r, "ABCDEGH")
|
||||
start, end = float(sec["start_m"]), float(sec["end_m"])
|
||||
cond = sec.get("condition") or "dry" # 2D 는 3D 와 같은 조건끼리 견줌
|
||||
inside = [
|
||||
float(s["fs_min"])
|
||||
float(s["min_by_condition"][cond])
|
||||
for s in rows2d
|
||||
if s.get("fs_min") is not None and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6
|
||||
if s["min_by_condition"].get(cond) is not None
|
||||
and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6
|
||||
]
|
||||
f3 = float(sec["f_min"])
|
||||
ok = f3 >= crit - 1e-9
|
||||
if sec.get("verdict") in ("ok", "ng"): # 판정 = E04 엔진 값(기준안전율도 E04)
|
||||
ok = sec["verdict"] == "ok"
|
||||
else:
|
||||
ok = f3 >= _criterion(ground, cond) - 1e-9
|
||||
_set(sheet, f"A{r}", f"{i + 1}구간" + (" (미리보기)" if sec.get("preview") else ""))
|
||||
_set(sheet, f"B{r}", _label_at(labels, start))
|
||||
_set(sheet, f"C{r}", _label_at(labels, end))
|
||||
@@ -571,6 +619,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,444 @@
|
||||
"""E06 암반 비탈면 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 (요구 345 · 351 · PLAN 536-1).
|
||||
|
||||
근거 = 지식DB `original/안정성평가/03`(Markland 부등식 · Hoek-Bray 평면 · 쐐기) · 06(상용 SW) ·
|
||||
Rocscience RocPlane · SWedge 이론서(힘 벡터 · 미끄럼 방향 8 경우 · 수직력 · 지지력은 수동력).
|
||||
코드는 직접 짬(Dips · RocPlane · SWedge 코드 안 씀) · NumPy · SciPy 만.
|
||||
|
||||
좌표 = 동(+x) · 북(+y) · 위(+z). 면 = (경사방향 α · 경사 ψ) · 선 = (추향 · 선주각).
|
||||
단위 = 길이 m · 단위중량 kN/m³ · 점착력 kPa · 각 ° · 힘 kN(평면 = 단위 폭당 kN/m).
|
||||
|
||||
⚠ 지식DB 03 의 평면 볼트 항(수직 T cos(θ+ψp) · 활동 −T sin(θ+ψp))은 θ = 볼트와 면 법선 사이 각일
|
||||
때 맞음 — 여기는 θ = 볼트 경사(수평 아래)로 받아 힘 벡터로 풂(수직 T sin(θ+ψp) · 활동 −T cos(θ+ψp)).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize_scalar
|
||||
|
||||
GAMMA_W = 9.81 # 물 단위중량 kN/m³ (E03 Types 와 같음)
|
||||
LATERAL_PLANAR_DEG = 20.0 # 평면 측방 한계(Dips 기본 · Wyllie & Mah)
|
||||
LATERAL_TOPPLING_DEG = 20.0 # 전도 측방 한계(Dips 기본 · 지식DB 03 은 30 — 화면에서 바꿈)
|
||||
|
||||
|
||||
# ---- 방향 ----------------------------------------------------------------------
|
||||
def ang_diff(a: float, b: float) -> float:
|
||||
"""두 방위각 사이 최소 각거리 0~180."""
|
||||
d = abs((a - b) % 360.0)
|
||||
return min(d, 360.0 - d)
|
||||
|
||||
|
||||
def plane_normal(dip_dir: float, dip: float) -> np.ndarray:
|
||||
"""면의 위쪽 단위 법선."""
|
||||
a, p = math.radians(dip_dir), math.radians(dip)
|
||||
return np.array([math.sin(a) * math.sin(p), math.cos(a) * math.sin(p), math.cos(p)])
|
||||
|
||||
|
||||
def line_vector(trend: float, plunge: float) -> np.ndarray:
|
||||
"""선(추향 · 선주각) → 아래로 향한 단위 벡터."""
|
||||
t, p = math.radians(trend), math.radians(plunge)
|
||||
return np.array([math.sin(t) * math.cos(p), math.cos(t) * math.cos(p), -math.sin(p)])
|
||||
|
||||
|
||||
def vector_line(v: np.ndarray) -> tuple[float, float]:
|
||||
"""벡터 → (추향 · 선주각) 하반구(위로 향하면 뒤집음)."""
|
||||
v = np.asarray(v, float)
|
||||
if v[2] > 0:
|
||||
v = -v
|
||||
v = v / np.linalg.norm(v)
|
||||
trend = math.degrees(math.atan2(v[0], v[1])) % 360.0
|
||||
return trend, math.degrees(math.asin(max(-1.0, min(1.0, -v[2]))))
|
||||
|
||||
|
||||
def pole(dip_dir: float, dip: float) -> tuple[float, float]:
|
||||
"""면의 극점(하반구) = (α + 180 · 90 − ψ)."""
|
||||
return (dip_dir + 180.0) % 360.0, 90.0 - dip
|
||||
|
||||
|
||||
def intersection(a: dict, b: dict) -> tuple[float, float] | None:
|
||||
"""두 면 교선(추향 · 선주각) — 나란하면 None."""
|
||||
v = np.cross(
|
||||
plane_normal(a["dip_direction"], a["dip"]), plane_normal(b["dip_direction"], b["dip"])
|
||||
)
|
||||
if np.linalg.norm(v) < 1e-9:
|
||||
return None
|
||||
return vector_line(v)
|
||||
|
||||
|
||||
def apparent_dip(dip: float, dip_dir: float, trend: float) -> float:
|
||||
"""면 (α · ψ) 의 trend 방향 겉보기 경사 — 반대쪽(90° 넘음)이면 음수."""
|
||||
return math.degrees(
|
||||
math.atan(math.tan(math.radians(dip)) * math.cos(math.radians(trend - dip_dir)))
|
||||
)
|
||||
|
||||
|
||||
def strike_to_dip_direction(strike: float) -> float:
|
||||
"""오른손 법칙 주향 → 경사방향."""
|
||||
return (strike + 90.0) % 360.0
|
||||
|
||||
|
||||
# ---- Markland 판정 -------------------------------------------------------------
|
||||
def kinematic(
|
||||
slope: dict,
|
||||
joints: list[dict],
|
||||
lateral_planar: float = LATERAL_PLANAR_DEG,
|
||||
lateral_toppling: float = LATERAL_TOPPLING_DEG,
|
||||
) -> dict[str, list]:
|
||||
"""slope = {dip_direction, dip} · joints = [{id, dip_direction, dip, phi}, …].
|
||||
|
||||
평면 = Δ(αf, αp) ≤ 측방 · φ < ψp < 겉보기 사면 경사 / 쐐기 = 교선 Δ < 90 · φ < 선주각 <
|
||||
겉보기 사면 경사(φ = 두 면 중 작은 값) /
|
||||
전도 = Δ(αf + 180, αp) ≤ 측방 · ψp > 90 − ψf + φ (Goodman).
|
||||
"""
|
||||
af, pf = float(slope["dip_direction"]), float(slope["dip"])
|
||||
planar, toppling, wedge = [], [], []
|
||||
for j in joints:
|
||||
aj, pj, phi = float(j["dip_direction"]), float(j["dip"]), float(j["phi"])
|
||||
if ang_diff(af, aj) <= lateral_planar and phi < pj < apparent_dip(pf, af, aj):
|
||||
planar.append(j["id"])
|
||||
if ang_diff(af + 180.0, aj) <= lateral_toppling and pj > 90.0 - pf + phi:
|
||||
toppling.append(j["id"])
|
||||
for i, a in enumerate(joints):
|
||||
for b in joints[i + 1 :]:
|
||||
line = intersection(a, b)
|
||||
if line is None:
|
||||
continue
|
||||
trend, plunge = line
|
||||
phi = min(float(a["phi"]), float(b["phi"]))
|
||||
if ang_diff(af, trend) < 90.0 and phi < plunge < apparent_dip(pf, af, trend):
|
||||
wedge.append(
|
||||
{
|
||||
"ids": [a["id"], b["id"]],
|
||||
"trend": round(trend, 2),
|
||||
"plunge": round(plunge, 2),
|
||||
}
|
||||
)
|
||||
return {"planar": planar, "wedge": wedge, "toppling": toppling}
|
||||
|
||||
|
||||
# ---- 평면 파괴 (Hoek-Bray · 2D 단위 폭) -----------------------------------------
|
||||
def _plane_block(H: float, pf: float, pp: float, ps: float, crack_x: float | None):
|
||||
"""사면 끝(0,0) · x = 산 쪽 수평 · z = 위. 덩이 다각형 · 활동면 끝 x · 균열 깊이.
|
||||
|
||||
윗면 = 마루(H cotψf, H)에서 ψs 로 오름. 균열 없음 = 활동면이 윗면과 만나는 점까지."""
|
||||
tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps))
|
||||
xc = H / tf # 마루
|
||||
|
||||
def top(x: float) -> float:
|
||||
return x * tf if x <= xc else H + (x - xc) * ts
|
||||
|
||||
if crack_x is None:
|
||||
if ts >= tp - 1e-12:
|
||||
return None # 활동면이 윗면과 안 만남 = 덩이 안 생김
|
||||
xe = (H - xc * ts) / (tp - ts)
|
||||
poly = [(0.0, 0.0), (xc, H), (xe, xe * tp)]
|
||||
return poly, xe, 0.0
|
||||
if crack_x < xc or top(crack_x) - crack_x * tp <= 1e-9:
|
||||
return None
|
||||
poly = [(0.0, 0.0), (xc, H), (crack_x, top(crack_x)), (crack_x, crack_x * tp)]
|
||||
return poly, crack_x, top(crack_x) - crack_x * tp
|
||||
|
||||
|
||||
def _area(poly) -> float:
|
||||
xs, zs = np.array(poly).T
|
||||
return 0.5 * abs(float(np.dot(xs, np.roll(zs, -1)) - np.dot(zs, np.roll(xs, -1))))
|
||||
|
||||
|
||||
def plane_fs(
|
||||
*,
|
||||
H: float,
|
||||
slope_dip: float,
|
||||
plane_dip: float,
|
||||
c: float,
|
||||
phi: float,
|
||||
gamma: float,
|
||||
upper_dip: float = 0.0,
|
||||
crack: dict | None = None,
|
||||
water_fill: float = 0.0,
|
||||
kh: float = 0.0,
|
||||
bolt_t: float = 0.0,
|
||||
bolt_plunge: float = 0.0,
|
||||
gamma_w: float = GAMMA_W,
|
||||
) -> dict[str, Any] | None:
|
||||
"""평면 파괴 안전율 — 덩이가 안 생기면 None.
|
||||
|
||||
crack = None | {"mode": "depth", "z": m} | {"mode": "critical"}(안전율 최소 자리 찾음) ·
|
||||
물 = 균열 있음: zw = fill·z · U = ½γw zw A · V = ½γw zw² / 균열 없음: 가운데 최대 수두
|
||||
fill·h/2(h = 활동면 오름) · U = ½γw (fill·h/2) A (Hoek · RocPlane) ·
|
||||
kh = 바깥 수평 · 볼트 = 수동력(산 쪽 · 수평 아래 bolt_plunge°)."""
|
||||
if not 0 < plane_dip < slope_dip < 90 or H <= 0 or upper_dip >= slope_dip:
|
||||
return None
|
||||
mode = (crack or {}).get("mode", "none")
|
||||
if mode == "critical":
|
||||
return _critical_crack(locals())
|
||||
tp = math.tan(math.radians(plane_dip))
|
||||
xc = H / math.tan(math.radians(slope_dip))
|
||||
crack_x = None
|
||||
if mode == "depth":
|
||||
z = float(crack["z"])
|
||||
crack_x = _crack_x_for_depth(H, slope_dip, plane_dip, upper_dip, z)
|
||||
if crack_x is None:
|
||||
return None
|
||||
elif mode == "x":
|
||||
crack_x = xc + float(crack["b"])
|
||||
block = _plane_block(H, slope_dip, plane_dip, upper_dip, crack_x)
|
||||
if block is None:
|
||||
return None
|
||||
poly, x_end, z = block
|
||||
psi = math.radians(plane_dip)
|
||||
A = x_end / math.cos(psi)
|
||||
W = gamma * _area(poly)
|
||||
if crack_x is not None:
|
||||
zw = water_fill * z
|
||||
U, V = 0.5 * gamma_w * zw * A, 0.5 * gamma_w * zw * zw
|
||||
else:
|
||||
zw = water_fill * (x_end * tp) / 2.0
|
||||
U, V = 0.5 * gamma_w * zw * A, 0.0
|
||||
d = np.array([-math.cos(psi), -math.sin(psi)]) # 아래로 미끄러짐(바깥 = −x)
|
||||
n = np.array([-math.sin(psi), math.cos(psi)]) # 면에서 덩이 쪽
|
||||
active = np.array([-kh * W - V, -W]) + U * n
|
||||
drive = float(active @ d)
|
||||
if drive <= 0:
|
||||
return None
|
||||
bolt = bolt_t * np.array(
|
||||
[math.cos(math.radians(bolt_plunge)), -math.sin(math.radians(bolt_plunge))]
|
||||
)
|
||||
N = max(0.0, -float((active + bolt) @ n))
|
||||
resist = c * A + N * math.tan(math.radians(phi)) - float(bolt @ d)
|
||||
return {
|
||||
"F": resist / drive,
|
||||
"W": W,
|
||||
"A": A,
|
||||
"U": U,
|
||||
"V": V,
|
||||
"z": z,
|
||||
"zw": zw,
|
||||
"crack_b": None if crack_x is None else crack_x - xc,
|
||||
"N": N,
|
||||
"drive": drive,
|
||||
"resist": resist,
|
||||
"polygon": [[round(x, 3), round(y, 3)] for x, y in poly],
|
||||
}
|
||||
|
||||
|
||||
def _crack_x_for_depth(H, pf, pp, ps, z) -> float | None:
|
||||
"""윗면 아래 균열 깊이 z 가 되는 자리(마루 뒤) — 깊이가 마루 틈보다 크면 None."""
|
||||
tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps))
|
||||
xc = H / tf
|
||||
gap = H - xc * tp # 마루에서 윗면 − 활동면
|
||||
if z <= 0 or z >= gap:
|
||||
return None
|
||||
if ts >= tp: # 틈이 커지기만 함 = 뒤로 갈수록 깊어짐
|
||||
return xc + (z - gap) / (ts - tp) if ts > tp else None
|
||||
return xc + (gap - z) / (tp - ts)
|
||||
|
||||
|
||||
def plane_critical_depth_hoek(H: float, slope_dip: float, plane_dip: float) -> float:
|
||||
"""Hoek-Bray 임계 인장균열 깊이(윗면 수평 · 건조) z = H(1 − √(cotψf tanψp))."""
|
||||
return H * (
|
||||
1.0 - math.sqrt(math.tan(math.radians(plane_dip)) / math.tan(math.radians(slope_dip)))
|
||||
)
|
||||
|
||||
|
||||
def _critical_crack(kw: dict) -> dict | None:
|
||||
"""균열 자리를 마루 뒤로 옮겨 안전율 최소 — 균열 없음보다 크면 균열 없음."""
|
||||
args = {k: kw[k] for k in kw if k not in ("crack", "mode")}
|
||||
tf, tp, ts = (math.tan(math.radians(kw[k])) for k in ("slope_dip", "plane_dip", "upper_dip"))
|
||||
xc = kw["H"] / tf
|
||||
x_far = (kw["H"] - xc * ts) / (tp - ts) if ts < tp else xc + 10 * kw["H"]
|
||||
span = x_far - xc
|
||||
|
||||
def fs_at(b: float) -> float:
|
||||
r = plane_fs(**args, crack={"mode": "x", "b": b})
|
||||
return r["F"] if r else math.inf
|
||||
|
||||
best = minimize_scalar(fs_at, bounds=(span * 1e-3, span * (1 - 1e-3)), method="bounded")
|
||||
found = plane_fs(**args, crack={"mode": "x", "b": float(best.x)})
|
||||
plain = plane_fs(**args, crack=None)
|
||||
if found is None or (plain is not None and plain["F"] <= found["F"]):
|
||||
return plain
|
||||
return found
|
||||
|
||||
|
||||
# ---- 쐐기 파괴 (Hoek-Bray · SWedge 꼴 사면체) ------------------------------------
|
||||
def _meet(planes: list[tuple[np.ndarray, float]]) -> np.ndarray | None:
|
||||
"""세 면 n·x = d 의 교점."""
|
||||
m = np.array([p[0] for p in planes])
|
||||
if abs(np.linalg.det(m)) < 1e-10:
|
||||
return None
|
||||
return np.linalg.solve(m, np.array([p[1] for p in planes]))
|
||||
|
||||
|
||||
def _tri_area(a, b, c) -> float:
|
||||
return 0.5 * float(np.linalg.norm(np.cross(b - a, c - a)))
|
||||
|
||||
|
||||
def wedge_geometry(j1: dict, j2: dict, slope: dict, upper: dict, H: float) -> dict | None:
|
||||
"""사면체 쐐기 — 꼭짓점 O(두 절리 · 사면 = 사면 끝) · A(1 · 사면 · 윗면) · B(2 · 사면 · 윗면) ·
|
||||
C(1 · 2 · 윗면). 사면 높이 H = 사면 위 꼭짓점 A · B 중 높은 쪽의 O 위 높이(SWedge 와 같음 —
|
||||
Priest 예제 부피 45.201 m³ 재현). 교선이 사면 · 윗면으로 안 나오면 None(쐐기 안 생김)."""
|
||||
n1 = plane_normal(j1["dip_direction"], j1["dip"])
|
||||
n2 = plane_normal(j2["dip_direction"], j2["dip"])
|
||||
nf = plane_normal(slope["dip_direction"], slope["dip"])
|
||||
nu = plane_normal(upper["dip_direction"], upper["dip"])
|
||||
# 윗면 = 사면 위로 H 오른 점을 지남(사면 경사방향 반대로 H cotψf 들어간 점)
|
||||
back = -line_vector(slope["dip_direction"], 0.0) * (H / math.tan(math.radians(slope["dip"])))
|
||||
crest = back + np.array([0.0, 0.0, H])
|
||||
du = float(nu @ crest)
|
||||
O = np.zeros(3) # noqa: E741 — 꼭짓점 이름(SWedge 그림과 같음)
|
||||
A = _meet([(n1, 0.0), (nf, 0.0), (nu, du)])
|
||||
B = _meet([(n2, 0.0), (nf, 0.0), (nu, du)])
|
||||
C = _meet([(n1, 0.0), (n2, 0.0), (nu, du)])
|
||||
if A is None or B is None or C is None:
|
||||
return None
|
||||
# C 는 사면 뒤(산 쪽) · 위 — 교선이 사면으로 나와 윗면에 닿음
|
||||
if C[2] <= 1e-9 or float(nf @ C) >= -1e-9 or A[2] <= 1e-9 or B[2] <= 1e-9:
|
||||
return None
|
||||
scale = H / max(A[2], B[2]) # 면이 모두 O 를 지나 닮은꼴 — 높이만 맞춤
|
||||
A, B, C = A * scale, B * scale, C * scale
|
||||
volume = abs(float(np.linalg.det(np.array([A - O, B - O, C - O])))) / 6.0
|
||||
if volume <= 1e-9:
|
||||
return None
|
||||
centroid = (O + A + B + C) / 4.0
|
||||
inward = [n if float(n @ centroid) > 0 else -n for n in (n1, n2)]
|
||||
return {
|
||||
"vertices": {"O": O, "A": A, "B": B, "C": C},
|
||||
"volume": volume,
|
||||
"areas": [_tri_area(O, A, C), _tri_area(O, B, C)],
|
||||
"face_area": _tri_area(O, A, B),
|
||||
"upper_area": _tri_area(A, B, C),
|
||||
"normals": inward,
|
||||
"Hw": float(C[2]),
|
||||
"line_length": float(np.linalg.norm(C)),
|
||||
}
|
||||
|
||||
|
||||
def _unit(v: np.ndarray) -> np.ndarray:
|
||||
n = float(np.linalg.norm(v))
|
||||
return v / n if n > 1e-12 else v * 0.0
|
||||
|
||||
|
||||
def _sliding(A: np.ndarray, n1: np.ndarray, n2: np.ndarray, W: np.ndarray):
|
||||
"""SWedge 미끄럼 방향 — (갈래, 방향). 갈래 = lift · 1 · 2 · 12 · stable."""
|
||||
s1 = _unit(np.cross(np.cross(n1, A), n1))
|
||||
s2 = _unit(np.cross(np.cross(n2, A), n2))
|
||||
c12 = np.cross(n1, n2)
|
||||
s12 = _unit(c12) * np.sign(float(c12 @ A))
|
||||
if A @ n1 > 0 and A @ n2 > 0:
|
||||
return "lift", _unit(A)
|
||||
if A @ n1 <= 0 and s1 @ n2 > 0:
|
||||
return "1", s1
|
||||
if A @ n2 <= 0 and s2 @ n1 > 0:
|
||||
return "2", s2
|
||||
if s1 @ n2 <= 0 and s2 @ n1 <= 0:
|
||||
return "12", s12
|
||||
return "stable", None
|
||||
|
||||
|
||||
def _normals(mode: str, F: np.ndarray, n1: np.ndarray, n2: np.ndarray) -> tuple[float, float]:
|
||||
if mode == "1":
|
||||
return -float(F @ n1), 0.0
|
||||
if mode == "2":
|
||||
return 0.0, -float(F @ n2)
|
||||
if mode == "12":
|
||||
c12, c21 = np.cross(n1, n2), np.cross(n2, n1)
|
||||
return (
|
||||
-float(np.cross(F, n2) @ c12) / float(c12 @ c12),
|
||||
-float(np.cross(F, n1) @ c21) / float(c21 @ c21),
|
||||
)
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def wedge_fs(
|
||||
*,
|
||||
j1: dict,
|
||||
j2: dict,
|
||||
slope: dict,
|
||||
upper: dict,
|
||||
H: float,
|
||||
gamma: float,
|
||||
water: dict | None = None,
|
||||
kh: float = 0.0,
|
||||
seismic_trend: float | None = None,
|
||||
external: list[dict] | None = None,
|
||||
bolt: dict | None = None,
|
||||
gamma_w: float = GAMMA_W,
|
||||
) -> dict[str, Any] | None:
|
||||
"""쐐기 안전율(SWedge 이론서 꼴) — 쐐기가 안 생기면 None.
|
||||
|
||||
j = {dip_direction, dip, c, phi} · water = {"fill": p}(중력식 U = p³γw Hw a/6) |
|
||||
{"pressure": [u1, u2]}(평균 수압 kPa) · kh = 수평(기본 교선 추향) · external = [{force, trend,
|
||||
plunge}] · bolt = {force, trend, plunge}(수동력). FS = max(지지 전 · 지지 뒤 · 들림)."""
|
||||
geo = wedge_geometry(j1, j2, slope, upper, H)
|
||||
if geo is None:
|
||||
return None
|
||||
n1, n2 = geo["normals"]
|
||||
a1, a2 = geo["areas"]
|
||||
weight = gamma * geo["volume"]
|
||||
Wv = np.array([0.0, 0.0, -weight])
|
||||
active = Wv.copy()
|
||||
water = water or {}
|
||||
if "pressure" in water:
|
||||
u1, u2 = (float(u) for u in water["pressure"])
|
||||
else:
|
||||
p = float(water.get("fill", 0.0))
|
||||
u1 = u2 = p**3 * gamma_w * geo["Hw"] / 6.0
|
||||
Uf = [u1 * a1 * n1, u2 * a2 * n2]
|
||||
active += Uf[0] + Uf[1]
|
||||
line = vector_line(np.cross(n1, n2))
|
||||
if kh:
|
||||
trend = line[0] if seismic_trend is None else seismic_trend
|
||||
active += kh * weight * line_vector(trend, 0.0)
|
||||
for item in external or []:
|
||||
active += float(item["force"]) * line_vector(item["trend"], item["plunge"])
|
||||
passive = np.zeros(3)
|
||||
if bolt and bolt.get("force"):
|
||||
passive = float(bolt["force"]) * line_vector(bolt["trend"], bolt["plunge"])
|
||||
mode, s = _sliding(active, n1, n2, Wv)
|
||||
out = {
|
||||
"volume": geo["volume"],
|
||||
"W": weight,
|
||||
"areas": [a1, a2],
|
||||
"face_area": geo["face_area"],
|
||||
"upper_area": geo["upper_area"],
|
||||
"Hw": geo["Hw"],
|
||||
"line_length": geo["line_length"],
|
||||
"line": [round(line[0], 2), round(line[1], 2)],
|
||||
"water_force": [float(np.linalg.norm(Uf[0])), float(np.linalg.norm(Uf[1]))],
|
||||
"mode": mode,
|
||||
"vertices": {k: [round(float(x), 3) for x in v] for k, v in geo["vertices"].items()},
|
||||
}
|
||||
if mode == "stable":
|
||||
return {**out, "F": math.inf, "N": [0.0, 0.0], "drive": 0.0, "resist": 0.0}
|
||||
drive = float(active @ s)
|
||||
if mode == "lift":
|
||||
fs = -float(passive @ s) / drive if drive > 0 else math.inf
|
||||
return {**out, "F": fs, "N": [0.0, 0.0], "drive": drive, "resist": -float(passive @ s)}
|
||||
joints = (j1, j2)
|
||||
|
||||
def resisting(F: np.ndarray) -> tuple[float, list[float]]:
|
||||
N = _normals(mode, F, n1, n2)
|
||||
total = 0.0
|
||||
for k, (n_k, a_k) in enumerate(zip(N, (a1, a2))):
|
||||
if str(k + 1) not in mode or n_k <= 0:
|
||||
continue # 떨어진 면 = 저항 없음(SWedge 와 같음)
|
||||
tau = float(joints[k]["c"]) + n_k / a_k * math.tan(
|
||||
math.radians(float(joints[k]["phi"]))
|
||||
)
|
||||
total += tau * a_k
|
||||
return total, [max(0.0, n) for n in N]
|
||||
|
||||
j_u, n_u = resisting(active)
|
||||
fs_u = j_u / drive if drive > 0 else math.inf
|
||||
fs, n_used, resist = fs_u, n_u, j_u
|
||||
if np.any(passive):
|
||||
j_s, n_s = resisting(active + passive)
|
||||
fs_s = (-float(passive @ s) + j_s) / drive if drive > 0 else math.inf
|
||||
fs_lift = -float(passive @ _unit(active)) / float(np.linalg.norm(active))
|
||||
if fs_s > fs:
|
||||
fs, n_used, resist = fs_s, n_s, -float(passive @ s) + j_s
|
||||
fs = max(fs, fs_lift)
|
||||
return {**out, "F": fs, "N": n_used, "drive": drive, "resist": resist}
|
||||
@@ -0,0 +1,446 @@
|
||||
"""E06 절리 정본 꼴 · 검사 · 측점 해석 묶음 (요구 345 · 351 · PLAN 536-1).
|
||||
|
||||
정본 = `E06_StabRock/joints.json` · 초기값 = `initial_snapshot/E06_StabRock/joints.json`
|
||||
(절리 없음 + 기본 설정 · 처음 GET 때 한 번). 절리군 한 줄 = 구간(from_m ~ to_m) · 경사방향 · 경사 ·
|
||||
φj · cj · 지속성(선택). 측점 = 복사본 암 측점(리핑암 · 발파암)의 깎기 쪽만.
|
||||
|
||||
사면 = 측점 frame 방향 + 깎기 쪽(경사방향 = 노면 쪽) · 경사 = 깎기(암) 경사비 · 높이 = E03 과 같은
|
||||
`side_cases` 높이 · 윗면 = 비탈 끝 바깥 원지반 경사. 물성 · 지진 · 기준안전율 = E02 지반 조건.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common_util.common_util_station_key import chainage_key
|
||||
from E01_StabRegister import E01_StabRegister_Source as src
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model
|
||||
from E03_StabSection.E03_StabSection_Engine_Model import ground_from_cross, side_cases
|
||||
from E06_StabRock import E06_StabRock_Engine as engine
|
||||
from E06_StabRock.E06_StabRock_Stereonet import PROJECTIONS, stereonet
|
||||
|
||||
STAGE_DIR = "E06_StabRock"
|
||||
JOINTS_NAME = "joints.json"
|
||||
VERSION = 1
|
||||
ENGINE_FILES = ("E06_StabRock_Engine.py", "E06_StabRock_Stereonet.py", "E06_StabRock_Model.py")
|
||||
DEFAULT_GAMMA_R = 25.0 # 암 단위중량 — E02 암 물성이 없을 때(지식DB 07 Hoek & Bray 25~27)
|
||||
MIN_HEIGHT_M = 1.0 # 이보다 낮은 깎기는 암반 블록 검토 안 함
|
||||
CRACK_MODES = ("none", "critical", "depth")
|
||||
CONDITIONS = ("dry", "wet", "seismic")
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
"projection": "equal_angle",
|
||||
"lateral_planar_deg": engine.LATERAL_PLANAR_DEG,
|
||||
"lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG,
|
||||
"gamma_r": None, # None = E02 측점 암 물성 γt
|
||||
"water_fill_wet": 0.5, # 우기 수위 = 비탈 높이 ½(KDS 11 70 05 표 4.3-3 암반)
|
||||
"crack": {"mode": "none", "z": None},
|
||||
"bolt": {"force": 0.0, "plunge": 15.0},
|
||||
}
|
||||
|
||||
# 칸 범위 — 화면 입력 · 서버 검사 같은 값
|
||||
LIMITS: dict[str, list[float]] = {
|
||||
"chainage_m": [0.0, 100000.0],
|
||||
"dip_direction": [0.0, 360.0],
|
||||
"dip": [0.0, 90.0],
|
||||
"phi": [0.0, 60.0],
|
||||
"c": [0.0, 1000.0],
|
||||
"persistence_m": [0.0, 1000.0],
|
||||
"lateral_deg": [5.0, 45.0],
|
||||
"gamma_r": [15.0, 35.0],
|
||||
"water_fill_wet": [0.0, 1.0],
|
||||
"crack_z": [0.1, 100.0],
|
||||
"bolt_force": [0.0, 5000.0],
|
||||
"bolt_plunge": [-30.0, 60.0],
|
||||
}
|
||||
|
||||
# 기본 절리면 값(새 줄) — 지식DB 07 표 7: cj 0~30 · φj 25~35(Barton 기본마찰각) 보수 쪽
|
||||
NEW_SET = {"phi": 30.0, "c": 0.0}
|
||||
|
||||
|
||||
def joints_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / STAGE_DIR / JOINTS_NAME
|
||||
|
||||
|
||||
def initial_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / "initial_snapshot" / STAGE_DIR / JOINTS_NAME
|
||||
|
||||
|
||||
def build_initial() -> dict[str, Any]:
|
||||
return {"version": VERSION, "sets": [], "settings": copy.deepcopy(DEFAULT_SETTINGS)}
|
||||
|
||||
|
||||
def upgrade(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""빠진 설정 칸만 기본값으로 채운 사본."""
|
||||
out = copy.deepcopy(doc)
|
||||
settings = out.setdefault("settings", {})
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
settings.setdefault(key, copy.deepcopy(value))
|
||||
out.setdefault("sets", [])
|
||||
return out
|
||||
|
||||
|
||||
# ---- 검사 ----------------------------------------------------------------------
|
||||
def _num(value: Any) -> bool:
|
||||
return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _in(value: Any, key: str) -> bool:
|
||||
lo, hi = LIMITS[key]
|
||||
return _num(value) and lo <= value <= hi
|
||||
|
||||
|
||||
def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""틀린 칸 [{path, code}] — code = range · order · name · id_dup · enum · type."""
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def bad(path: str, code: str = "range") -> None:
|
||||
errors.append({"path": path, "code": code})
|
||||
|
||||
sets = doc.get("sets")
|
||||
if not isinstance(sets, list):
|
||||
return [{"path": "sets", "code": "type"}]
|
||||
seen: set[str] = set()
|
||||
for k, row in enumerate(sets):
|
||||
base = f"sets.{k}"
|
||||
if not isinstance(row, dict):
|
||||
bad(base, "type")
|
||||
continue
|
||||
ident = str(row.get("id") or "")
|
||||
if not ident or ident in seen:
|
||||
bad(f"{base}.id", "id_dup")
|
||||
seen.add(ident)
|
||||
if not str(row.get("name") or "").strip():
|
||||
bad(f"{base}.name", "name")
|
||||
for key, limit in (
|
||||
("from_m", "chainage_m"),
|
||||
("to_m", "chainage_m"),
|
||||
("dip_direction", "dip_direction"),
|
||||
("dip", "dip"),
|
||||
("phi", "phi"),
|
||||
("c", "c"),
|
||||
):
|
||||
if not _in(row.get(key), limit):
|
||||
bad(f"{base}.{key}")
|
||||
if _num(row.get("from_m")) and _num(row.get("to_m")) and row["to_m"] < row["from_m"]:
|
||||
bad(f"{base}.to_m", "order")
|
||||
if row.get("persistence_m") is not None and not _in(row["persistence_m"], "persistence_m"):
|
||||
bad(f"{base}.persistence_m")
|
||||
settings = doc.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
return errors + [{"path": "settings", "code": "type"}]
|
||||
if settings.get("projection") not in PROJECTIONS:
|
||||
bad("settings.projection", "enum")
|
||||
for key in ("lateral_planar_deg", "lateral_toppling_deg"):
|
||||
if not _in(settings.get(key), "lateral_deg"):
|
||||
bad(f"settings.{key}")
|
||||
if settings.get("gamma_r") is not None and not _in(settings["gamma_r"], "gamma_r"):
|
||||
bad("settings.gamma_r")
|
||||
if not _in(settings.get("water_fill_wet"), "water_fill_wet"):
|
||||
bad("settings.water_fill_wet")
|
||||
crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {}
|
||||
if crack.get("mode") not in CRACK_MODES:
|
||||
bad("settings.crack.mode", "enum")
|
||||
elif crack["mode"] == "depth" and not _in(crack.get("z"), "crack_z"):
|
||||
bad("settings.crack.z")
|
||||
bolt = settings.get("bolt") if isinstance(settings.get("bolt"), dict) else {}
|
||||
if not _in(bolt.get("force"), "bolt_force"):
|
||||
bad("settings.bolt.force")
|
||||
if not _in(bolt.get("plunge"), "bolt_plunge"):
|
||||
bad("settings.bolt.plunge")
|
||||
return errors
|
||||
|
||||
|
||||
def clean(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""정본 칸만 · 숫자는 float."""
|
||||
sets = []
|
||||
for row in doc["sets"]:
|
||||
item = {
|
||||
"id": str(row["id"]),
|
||||
"name": str(row["name"]).strip(),
|
||||
**{
|
||||
key: float(row[key])
|
||||
for key in ("from_m", "to_m", "dip_direction", "dip", "phi", "c")
|
||||
},
|
||||
"persistence_m": None
|
||||
if row.get("persistence_m") is None
|
||||
else float(row["persistence_m"]),
|
||||
}
|
||||
item["dip_direction"] %= 360.0
|
||||
sets.append(item)
|
||||
s = doc["settings"]
|
||||
crack = s["crack"]
|
||||
return {
|
||||
"version": VERSION,
|
||||
"sets": sets,
|
||||
"settings": {
|
||||
"projection": s["projection"],
|
||||
"lateral_planar_deg": float(s["lateral_planar_deg"]),
|
||||
"lateral_toppling_deg": float(s["lateral_toppling_deg"]),
|
||||
"gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]),
|
||||
"water_fill_wet": float(s["water_fill_wet"]),
|
||||
"crack": {
|
||||
"mode": crack["mode"],
|
||||
"z": float(crack["z"]) if crack["mode"] == "depth" else None,
|
||||
},
|
||||
"bolt": {"force": float(s["bolt"]["force"]), "plunge": float(s["bolt"]["plunge"])},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---- 측점 사면 ------------------------------------------------------------------
|
||||
def rock_stations(source: Path) -> list[dict[str, Any]]:
|
||||
"""암 측점(리핑암 · 발파암)."""
|
||||
return [s for s in src.stations(source) if s.get("ground_type") in ground_model.ROCK_LABELS]
|
||||
|
||||
|
||||
def _upper_dip(ground: list, start: float, sign: float, H: float, face_dip: float) -> float:
|
||||
"""비탈 끝 바깥 max(H, 5 m) 원지반 평균 경사(오르막만 · 사면 경사 − 1° 아래)."""
|
||||
gx = np.array([p[0] for p in ground])
|
||||
gz = np.array([p[1] for p in ground])
|
||||
end = start + sign * max(H, 5.0)
|
||||
end = min(max(end, gx[0]), gx[-1])
|
||||
run = abs(end - start)
|
||||
if run < 0.5:
|
||||
return 0.0
|
||||
rise = float(np.interp(end, gx, gz) - np.interp(start, gx, gz))
|
||||
return float(min(max(0.0, math.degrees(math.atan2(rise, run))), face_dip - 1.0))
|
||||
|
||||
|
||||
def station_slopes(cross: dict, design: dict) -> list[dict[str, Any]]:
|
||||
"""측점의 깎기 쪽마다 사면 — {side, dip_direction, dip, height_m, upper_dip}."""
|
||||
ground = ground_from_cross(cross)
|
||||
line = sorted(
|
||||
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
|
||||
)
|
||||
ratio = design.get("cut_slope_ratio")
|
||||
if len(ground) < 2 or len(line) < 2 or not ratio:
|
||||
return []
|
||||
face_dip = math.degrees(math.atan2(1.0, float(ratio)))
|
||||
left = np.array([float(v) for v in cross["frame"]["left_xy"]])
|
||||
out = []
|
||||
for case in side_cases(design, ground, line):
|
||||
if case["role"] != "cut" or case["height_m"] < MIN_HEIGHT_M:
|
||||
continue
|
||||
side = case["side"]
|
||||
sign = 1.0 if side == "left" else -1.0
|
||||
# 비탈 끝 = 탐색 끝에서 되짚음(side_cases: reach = 비탈 끝 + sign · max(5, 2H))
|
||||
day = case["limits"][1 if sign > 0 else 0] - sign * max(5.0, 2.0 * case["height_m"])
|
||||
face = -sign * left # 노면 쪽으로 기움
|
||||
dip_dir = math.degrees(math.atan2(face[0], face[1])) % 360.0
|
||||
out.append(
|
||||
{
|
||||
"side": side,
|
||||
"dip_direction": round(dip_dir, 2),
|
||||
"dip": round(face_dip, 2),
|
||||
"height_m": round(case["height_m"], 3),
|
||||
"upper_dip": round(_upper_dip(ground, day, sign, case["height_m"], face_dip), 2),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---- 해석 ----------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def engine_hash() -> str:
|
||||
digest = hashlib.sha1()
|
||||
for name in ENGINE_FILES:
|
||||
digest.update((Path(__file__).parent / name).read_bytes())
|
||||
return digest.hexdigest()[:12]
|
||||
|
||||
|
||||
def _ground_inputs(ground: dict[str, Any]) -> dict[str, Any]:
|
||||
"""E02 에서 E06 이 쓰는 칸 — 지문에도 이것만."""
|
||||
return {
|
||||
k: ground.get(k)
|
||||
for k in ("materials", "label_map", "stations", "zones", "safety_factors", "seismic")
|
||||
}
|
||||
|
||||
|
||||
def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -> float:
|
||||
if override:
|
||||
return float(override)
|
||||
mat = materials.get((resolved or {}).get("lower") or (resolved or {}).get("upper"))
|
||||
return float(mat["gamma_t"]) if mat else DEFAULT_GAMMA_R
|
||||
|
||||
|
||||
def _fmt(value: float | None) -> float | None:
|
||||
if value is None or not math.isfinite(value):
|
||||
return None
|
||||
return round(value, 4)
|
||||
|
||||
|
||||
def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds):
|
||||
"""위험 모드만 안전율 — 조건 = [(이름, 수위 비, kh)]."""
|
||||
upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]}
|
||||
by_id = {j["id"]: j for j in joints}
|
||||
bolt = settings["bolt"]
|
||||
crack = settings["crack"] if settings["crack"]["mode"] != "none" else None
|
||||
out = []
|
||||
for jid in kin["planar"]:
|
||||
j = by_id[jid]
|
||||
for name, fill, kh in conds:
|
||||
r = engine.plane_fs(
|
||||
H=slope["height_m"],
|
||||
slope_dip=slope["dip"],
|
||||
plane_dip=j["dip"],
|
||||
c=j["c"],
|
||||
phi=j["phi"],
|
||||
gamma=gamma,
|
||||
upper_dip=slope["upper_dip"],
|
||||
crack=crack,
|
||||
water_fill=fill,
|
||||
kh=kh,
|
||||
bolt_t=bolt["force"],
|
||||
bolt_plunge=bolt["plunge"],
|
||||
)
|
||||
short = (
|
||||
r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"]
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"mode": "planar",
|
||||
"ids": [jid],
|
||||
"condition": name,
|
||||
"F": _fmt(r["F"]) if r else None,
|
||||
"formed": r is not None,
|
||||
"persistence_short": short,
|
||||
"W": _fmt(r["W"]) if r else None,
|
||||
"A": _fmt(r["A"]) if r else None,
|
||||
"z": _fmt(r["z"]) if r else None,
|
||||
}
|
||||
)
|
||||
for item in kin["wedge"]:
|
||||
a, b = (by_id[k] for k in item["ids"])
|
||||
for name, fill, kh in conds:
|
||||
r = engine.wedge_fs(
|
||||
j1=a,
|
||||
j2=b,
|
||||
slope=slope,
|
||||
upper=upper,
|
||||
H=slope["height_m"],
|
||||
gamma=gamma,
|
||||
water={"fill": fill},
|
||||
kh=kh,
|
||||
bolt={
|
||||
"force": bolt["force"],
|
||||
"trend": (slope["dip_direction"] + 180.0) % 360.0,
|
||||
"plunge": bolt["plunge"],
|
||||
},
|
||||
)
|
||||
length = r["line_length"] if r else None
|
||||
short = any(
|
||||
j.get("persistence_m") is not None
|
||||
and length is not None
|
||||
and j["persistence_m"] < length
|
||||
for j in (a, b)
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"mode": "wedge",
|
||||
"ids": list(item["ids"]),
|
||||
"condition": name,
|
||||
"F": _fmt(r["F"]) if r else None,
|
||||
"formed": r is not None,
|
||||
"persistence_short": short,
|
||||
"sliding": r["mode"] if r else None,
|
||||
"W": _fmt(r["W"]) if r else None,
|
||||
"volume": _fmt(r["volume"]) if r else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]]:
|
||||
"""암 측점 × 깎기 쪽 — 사면 · 절리 · Markland · 안전율 · 판정."""
|
||||
settings = joints_doc["settings"]
|
||||
sets = joints_doc["sets"]
|
||||
materials = {m["id"]: m for m in ground.get("materials") or []}
|
||||
resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)}
|
||||
seismic = ground.get("seismic") or {}
|
||||
factors = ground.get("safety_factors") or {}
|
||||
conds = [("dry", 0.0, 0.0), ("wet", float(settings["water_fill_wet"]), 0.0)]
|
||||
if seismic.get("enabled") and seismic.get("kh"):
|
||||
conds.append(("seismic", 0.0, float(seismic["kh"])))
|
||||
out = []
|
||||
for station in rock_stations(source):
|
||||
ch = float(station["chainage_m"])
|
||||
key = chainage_key(ch)
|
||||
joints = [j for j in sets if j["from_m"] <= ch <= j["to_m"]]
|
||||
cross = src.cross_section(source, ch)
|
||||
gamma = _gamma_for(resolved.get(key), materials, settings.get("gamma_r"))
|
||||
for slope in station_slopes(cross, cross["design"]):
|
||||
kin = engine.kinematic(
|
||||
slope, joints, settings["lateral_planar_deg"], settings["lateral_toppling_deg"]
|
||||
)
|
||||
cases = _cases_for(slope, joints, kin, settings, gamma, conds)
|
||||
for case in cases:
|
||||
need = (factors.get(case["condition"]) or {}).get("cut")
|
||||
case["required"] = need
|
||||
case["ok"] = None if case["F"] is None or need is None else case["F"] >= need
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"chainage_m": ch,
|
||||
"label": station.get("label"),
|
||||
"ground_type": station.get("ground_type"),
|
||||
"slope": slope,
|
||||
"gamma_r": gamma,
|
||||
"joints": [
|
||||
{k: j[k] for k in ("id", "name", "dip_direction", "dip", "phi", "c")}
|
||||
for j in joints
|
||||
],
|
||||
"kinematic": kin,
|
||||
"cases": cases,
|
||||
**_verdict(kin, cases),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]:
|
||||
"""판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음)."""
|
||||
values = [c["F"] for c in cases if c["F"] is not None]
|
||||
min_f = min(values) if values else None
|
||||
if any(c["ok"] is False for c in cases):
|
||||
verdict = "NG"
|
||||
elif kin["toppling"] or (cases and not values):
|
||||
verdict = "check"
|
||||
elif cases:
|
||||
verdict = "OK"
|
||||
else:
|
||||
verdict = "none"
|
||||
return {
|
||||
"counts": {
|
||||
"planar": len(kin["planar"]),
|
||||
"wedge": len(kin["wedge"]),
|
||||
"toppling": len(kin["toppling"]),
|
||||
},
|
||||
"min_F": min_f,
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def station_net(entry: dict, settings: dict) -> dict[str, Any]:
|
||||
"""한 측점 쪽 스테레오넷(해석 때 절리 그대로) — 두 투영 모두(화면이 고름)."""
|
||||
joints = entry["joints"]
|
||||
phi = min((j["phi"] for j in joints), default=NEW_SET["phi"])
|
||||
nets = {
|
||||
kind: stereonet(
|
||||
entry["slope"],
|
||||
joints,
|
||||
phi,
|
||||
kind,
|
||||
settings["lateral_planar_deg"],
|
||||
settings["lateral_toppling_deg"],
|
||||
)
|
||||
for kind in PROJECTIONS
|
||||
}
|
||||
return {**nets, "phi": phi}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""E06 암반 평사투영 해석 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1).
|
||||
|
||||
- `GET /api/projects/{id}/stab/rock` — 절리 작업본(없으면 초기값) · 범위 · 암 측점 · 고칠 수 있는지
|
||||
- `PUT …/stab/rock` — [저장] 검사 뒤 작업본 `E06_StabRock/joints.json`
|
||||
- `POST …/stab/rock/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님)
|
||||
- `POST …/stab/rock/analyze` — [해석] 암 측점 × 깎기 쪽 · 지문이 같으면 안 셈(`fresh`)
|
||||
- `GET …/stab/rock/result` — 측점별 모드 · 안전율 · 판정(+ 지문이 지금과 다른지)
|
||||
- `GET …/stab/rock/result?station=&side=` — 그 측점 쪽 + 스테레오넷(등각 · 등면적)
|
||||
|
||||
입력 = E01 복사본 + E02 지반 조건(물성 · 지진 · 기준안전율) + 절리 작업본(저장 안 한 캐시는 안 씀).
|
||||
계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab_rock/<지문>`.
|
||||
공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_derived_store import (
|
||||
derived_dir,
|
||||
derived_key,
|
||||
read_derived,
|
||||
write_derived,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from E01_StabRegister import E01_StabRegister_Source as src
|
||||
from E02_StabGround import E02_StabGround_Router as e02
|
||||
from E06_StabRock import E06_StabRock_Model as model
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["E06 Stab Rock"])
|
||||
|
||||
KIND = "stab_rock"
|
||||
|
||||
|
||||
class JointsRequest(BaseModel):
|
||||
joints: dict[str, Any]
|
||||
|
||||
|
||||
def ensure_initial(project_root: Path) -> dict[str, Any]:
|
||||
"""초기값이 없을 때만 한 번 — 절리 없음 + 기본 설정."""
|
||||
path = model.initial_path(project_root)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
doc = model.build_initial()
|
||||
atomic_write_json(path, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def _working(project_root: Path) -> dict[str, Any] | None:
|
||||
path = model.joints_path(project_root)
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
|
||||
|
||||
|
||||
def _joints(project_root: Path) -> dict[str, Any]:
|
||||
return model.upgrade(_working(project_root) or ensure_initial(project_root))
|
||||
|
||||
|
||||
def _source(project_root: Path) -> Path:
|
||||
root = src.source_dir(project_root)
|
||||
if not (root / src.MANIFEST_NAME).is_file():
|
||||
raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.")
|
||||
return root
|
||||
|
||||
|
||||
def _fingerprint(project_root: Path, source: Path) -> tuple[str, dict, dict]:
|
||||
joints = _joints(project_root)
|
||||
ground = e02._read_working(project_root) or e02.ensure_initial(project_root)
|
||||
fp = derived_key(
|
||||
model.engine_hash(),
|
||||
src.manifest(source)["source_fingerprint"],
|
||||
{
|
||||
"sets": joints["sets"],
|
||||
"settings": {k: v for k, v in joints["settings"].items() if k != "projection"},
|
||||
},
|
||||
model._ground_inputs(ground),
|
||||
)
|
||||
return fp, joints, ground
|
||||
|
||||
|
||||
def _latest(project_root: Path) -> dict[str, Any] | None:
|
||||
"""가장 최근 저장본(지문이 달라도 — 화면이 「다시 해석」 띠를 띄움)."""
|
||||
folder = derived_dir(project_root, KIND)
|
||||
files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True)
|
||||
for path in files:
|
||||
saved = read_derived(project_root, KIND, path.stem)
|
||||
if saved:
|
||||
return saved
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/rock")
|
||||
async def get_rock(
|
||||
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root, project = await e02._project(project_id)
|
||||
initial = ensure_initial(root)
|
||||
working = _working(root)
|
||||
try:
|
||||
stations = [
|
||||
{"chainage_m": s["chainage_m"], "label": s["label"], "ground_type": s["ground_type"]}
|
||||
for s in model.rock_stations(_source(root))
|
||||
]
|
||||
except HTTPException:
|
||||
stations = []
|
||||
return {
|
||||
"joints": model.upgrade(working or initial),
|
||||
"saved": working is not None,
|
||||
"defaults": {
|
||||
"settings": model.DEFAULT_SETTINGS,
|
||||
"new_set": model.NEW_SET,
|
||||
"limits": model.LIMITS,
|
||||
},
|
||||
"stations": stations,
|
||||
"editable": e02._editable(session, project),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{project_id}/stab/rock")
|
||||
async def put_rock(project_id: UUID, payload: JointsRequest) -> dict[str, Any]:
|
||||
root, _ = await e02._project(project_id)
|
||||
errors = model.check_joints(payload.joints)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail={"errors": errors})
|
||||
doc = model.clean(payload.joints)
|
||||
atomic_write_json(model.joints_path(root), doc)
|
||||
return {"joints": doc}
|
||||
|
||||
|
||||
@router.post("/{project_id}/stab/rock/reset")
|
||||
async def reset_rock(project_id: UUID) -> dict[str, Any]:
|
||||
root, _ = await e02._project(project_id)
|
||||
initial = ensure_initial(root)
|
||||
atomic_write_json(model.joints_path(root), initial)
|
||||
return {"joints": model.upgrade(initial)}
|
||||
|
||||
|
||||
def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
verdicts: dict[str, int] = {}
|
||||
for item in stations:
|
||||
verdicts[item["verdict"]] = verdicts.get(item["verdict"], 0) + 1
|
||||
return {
|
||||
"slopes": len(stations),
|
||||
"verdicts": verdicts,
|
||||
"modes": {
|
||||
mode: sum(item["counts"][mode] for item in stations)
|
||||
for mode in ("planar", "wedge", "toppling")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _run(root: Path) -> dict[str, Any]:
|
||||
source = _source(root)
|
||||
fp, joints, ground = _fingerprint(root, source)
|
||||
if read_derived(root, KIND, fp):
|
||||
return {"status": "fresh", "fingerprint": fp}
|
||||
stations = model.analyze(source, joints, ground)
|
||||
value = {
|
||||
"fingerprint": fp,
|
||||
"settings": joints["settings"],
|
||||
"summary": _summary(stations),
|
||||
"stations": stations,
|
||||
}
|
||||
write_derived(root, KIND, fp, value)
|
||||
return {"status": "done", "fingerprint": fp, "summary": value["summary"]}
|
||||
|
||||
|
||||
@router.post("/{project_id}/stab/rock/analyze")
|
||||
async def analyze_rock(project_id: UUID) -> dict[str, Any]:
|
||||
"""[해석] — 암 측점 수십 개 · 1 초 안쪽이라 바로 셈(진행률 없음)."""
|
||||
root, _ = await e02._project(project_id)
|
||||
return await asyncio.to_thread(_run, root)
|
||||
|
||||
|
||||
def _result(root: Path, station: str | None, side: str | None) -> dict[str, Any]:
|
||||
source = _source(root)
|
||||
fp, _, _ = _fingerprint(root, source)
|
||||
saved = read_derived(root, KIND, fp) or _latest(root)
|
||||
if not saved:
|
||||
return {"status": "empty", "stale": False, "result": None}
|
||||
value = saved["value"]
|
||||
stale = value["fingerprint"] != fp
|
||||
if station is None:
|
||||
return {"status": "success", "stale": stale, "result": value}
|
||||
for entry in value["stations"]:
|
||||
if entry["key"] == station and (side is None or entry["slope"]["side"] == side):
|
||||
net = model.station_net(entry, value["settings"])
|
||||
return {"status": "success", "stale": stale, "entry": entry, "stereonet": net}
|
||||
raise HTTPException(status_code=404, detail="그 측점의 암반 해석 결과가 없습니다.")
|
||||
|
||||
|
||||
@router.get("/{project_id}/stab/rock/result")
|
||||
async def result_rock(
|
||||
project_id: UUID, station: str | None = None, side: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
root, _ = await e02._project(project_id)
|
||||
return await asyncio.to_thread(_result, root, station, side)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""E06 스테레오넷 투영 좌표 — 하반구 · 등각(Wulff) · 등면적(Schmidt) (PLAN 536-1).
|
||||
|
||||
화면(SVG)은 이 좌표를 그리기만 함 — 반지름 1 원 · x = 동 · y = 북(화면은 y 를 뒤집음).
|
||||
극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역(Markland 부등식과 같은 경계 —
|
||||
`E06_StabRock_Engine.kinematic`)을 냄. 투영식 = 표준 구면 투영(mplstereonet 등은 대조만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from E06_StabRock.E06_StabRock_Engine import (
|
||||
LATERAL_PLANAR_DEG,
|
||||
LATERAL_TOPPLING_DEG,
|
||||
apparent_dip,
|
||||
intersection,
|
||||
pole,
|
||||
)
|
||||
|
||||
PROJECTIONS = ("equal_angle", "equal_area")
|
||||
STEP_DEG = 2.0 # 곡선 표본 간격
|
||||
|
||||
|
||||
def radius(plunge: float, kind: str) -> float:
|
||||
"""선주각 → 중심에서 거리(0 = 연직 · 1 = 수평)."""
|
||||
half = math.radians(90.0 - plunge) / 2.0
|
||||
return math.tan(half) if kind == "equal_angle" else math.sqrt(2.0) * math.sin(half)
|
||||
|
||||
|
||||
def project(trend: float, plunge: float, kind: str) -> list[float]:
|
||||
r = radius(plunge, kind)
|
||||
t = math.radians(trend)
|
||||
return [round(r * math.sin(t), 5), round(r * math.cos(t), 5)]
|
||||
|
||||
|
||||
def inverse(x: float, y: float, kind: str) -> tuple[float, float]:
|
||||
"""투영 좌표 → (추향 · 선주각) — 왕복 시험 · 화면 마우스 자리 읽기."""
|
||||
r = math.hypot(x, y)
|
||||
half = math.atan(r) if kind == "equal_angle" else math.asin(min(1.0, r / math.sqrt(2.0)))
|
||||
return math.degrees(math.atan2(x, y)) % 360.0, 90.0 - math.degrees(2.0 * half)
|
||||
|
||||
|
||||
def _samples(lo: float, hi: float) -> list[float]:
|
||||
n = max(2, int(math.ceil((hi - lo) / STEP_DEG)) + 1)
|
||||
return [lo + (hi - lo) * k / (n - 1) for k in range(n)]
|
||||
|
||||
|
||||
def great_circle(dip_dir: float, dip: float, kind: str) -> list[list[float]]:
|
||||
"""면의 대원 — 주향 한끝 → 경사 방향 → 다른 끝."""
|
||||
return [
|
||||
project(dip_dir + b, max(0.0, apparent_dip(dip, dip_dir, dip_dir + b)), kind)
|
||||
for b in _samples(-90.0, 90.0)
|
||||
]
|
||||
|
||||
|
||||
def _zone(trend_lo, trend_hi, plunge_lo, plunge_hi, kind) -> list[list[float]] | None:
|
||||
"""(추향 · 선주각) 띠 → 닫힌 다각형 — plunge_lo/hi 는 추향의 함수 · 비면 None."""
|
||||
ts = _samples(trend_lo, trend_hi)
|
||||
lows = [plunge_lo(t) for t in ts]
|
||||
highs = [plunge_hi(t) for t in ts]
|
||||
keep = [k for k in range(len(ts)) if highs[k] > lows[k]]
|
||||
if len(keep) < 2:
|
||||
return None
|
||||
outer = [project(ts[k], lows[k], kind) for k in keep]
|
||||
inner = [project(ts[k], highs[k], kind) for k in reversed(keep)]
|
||||
return outer + inner
|
||||
|
||||
|
||||
def stereonet(
|
||||
slope: dict,
|
||||
joints: list[dict],
|
||||
phi: float,
|
||||
kind: str = "equal_angle",
|
||||
lateral_planar: float = LATERAL_PLANAR_DEG,
|
||||
lateral_toppling: float = LATERAL_TOPPLING_DEG,
|
||||
) -> dict[str, Any]:
|
||||
"""slope = {dip_direction, dip} · joints = [{id, dip_direction, dip}] · phi = 영역 마찰각."""
|
||||
af, pf = float(slope["dip_direction"]), float(slope["dip"])
|
||||
|
||||
def face_app(t: float) -> float:
|
||||
return apparent_dip(pf, af, t)
|
||||
|
||||
planes = []
|
||||
for j in joints:
|
||||
tr, pl = pole(j["dip_direction"], j["dip"])
|
||||
planes.append(
|
||||
{
|
||||
"id": j["id"],
|
||||
"pole": project(tr, pl, kind),
|
||||
"great_circle": great_circle(j["dip_direction"], j["dip"], kind),
|
||||
}
|
||||
)
|
||||
lines = []
|
||||
for i, a in enumerate(joints):
|
||||
for b in joints[i + 1 :]:
|
||||
line = intersection(a, b)
|
||||
if line is not None:
|
||||
lines.append({"ids": [a["id"], b["id"]], "point": project(*line, kind)})
|
||||
# 일광 엔벨로프(극점 꼴) = 사면 겉보기 경사로 기운 면들의 극점
|
||||
envelope = [
|
||||
project(af + b + 180.0, 90.0 - face_app(af + b), kind) for b in _samples(-90.0, 90.0)
|
||||
]
|
||||
zones = {
|
||||
# 평면(극점) — 극점 추향 αf + 180 ± 측방 · 선주각 90 − ψapp ~ 90 − φ
|
||||
"planar": _zone(
|
||||
af + 180.0 - lateral_planar,
|
||||
af + 180.0 + lateral_planar,
|
||||
lambda t: 90.0 - face_app(t - 180.0),
|
||||
lambda t: 90.0 - phi,
|
||||
kind,
|
||||
),
|
||||
# 쐐기(교선) — 추향 αf ± 90 · 선주각 φ ~ ψapp
|
||||
"wedge": _zone(af - 90.0, af + 90.0, lambda t: phi, face_app, kind),
|
||||
# 전도(극점) — 극점 추향 αf ± 측방 · 선주각 0 ~ ψf − φ
|
||||
"toppling": _zone(
|
||||
af - lateral_toppling,
|
||||
af + lateral_toppling,
|
||||
lambda t: 0.0,
|
||||
lambda t: pf - phi,
|
||||
kind,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"projection": kind,
|
||||
"slope": {
|
||||
"pole": project(*pole(af, pf), kind),
|
||||
"great_circle": great_circle(af, pf, kind),
|
||||
},
|
||||
"planes": planes,
|
||||
"intersections": lines,
|
||||
# 마찰원 — 극점 꼴 = 중심에서 φ(선주각 90 − φ) · 교선 꼴 = 선주각 φ
|
||||
"friction_pole_r": round(radius(90.0 - phi, kind), 5),
|
||||
"friction_line_r": round(radius(phi, kind), 5),
|
||||
"daylight_envelope": envelope,
|
||||
"zones": zones,
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Joints.ts
|
||||
* E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정
|
||||
* (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange —
|
||||
* 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
|
||||
export interface JointSet {
|
||||
id: string;
|
||||
name: string;
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
dip_direction: number;
|
||||
dip: number;
|
||||
phi: number;
|
||||
c: number;
|
||||
persistence_m: number | null;
|
||||
}
|
||||
|
||||
export type Projection = "equal_angle" | "equal_area";
|
||||
|
||||
export interface RockSettings {
|
||||
projection: Projection;
|
||||
lateral_planar_deg: number;
|
||||
lateral_toppling_deg: number;
|
||||
gamma_r: number | null;
|
||||
water_fill_wet: number;
|
||||
crack: { mode: "none" | "critical" | "depth"; z: number | null };
|
||||
bolt: { force: number; plunge: number };
|
||||
}
|
||||
|
||||
export interface JointsDoc {
|
||||
version: number;
|
||||
sets: JointSet[];
|
||||
settings: RockSettings;
|
||||
}
|
||||
|
||||
export interface RockDefaults {
|
||||
settings: RockSettings;
|
||||
new_set: { phi: number; c: number };
|
||||
limits: Record<string, [number, number]>;
|
||||
}
|
||||
|
||||
export interface JointsView {
|
||||
doc: JointsDoc;
|
||||
defaults: RockDefaults;
|
||||
editable: boolean;
|
||||
endM: number; // 마지막 암 측점(새 줄 구간 끝)
|
||||
change: () => void; // 값만 바뀜
|
||||
rebuild: () => void; // 줄 수가 바뀜
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */
|
||||
function numberInput(
|
||||
view: JointsView,
|
||||
value: number | null,
|
||||
limit: string,
|
||||
set: (v: number | null) => void,
|
||||
opts: { optional?: boolean; step?: string; path: string },
|
||||
): HTMLInputElement {
|
||||
const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity];
|
||||
const input = el("input", {
|
||||
className: "e06-input",
|
||||
attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path },
|
||||
}) as HTMLInputElement;
|
||||
input.value = value === null ? "" : String(value);
|
||||
input.disabled = !view.editable;
|
||||
const check = (): boolean => {
|
||||
const raw = input.value.trim();
|
||||
const num = Number(raw);
|
||||
const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi;
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`;
|
||||
return ok;
|
||||
};
|
||||
input.addEventListener("input", () => {
|
||||
const raw = input.value.trim();
|
||||
set(raw === "" ? null : Number(raw));
|
||||
check();
|
||||
view.change();
|
||||
});
|
||||
check();
|
||||
return input;
|
||||
}
|
||||
|
||||
function nextId(sets: JointSet[]): string {
|
||||
let k = sets.length + 1;
|
||||
while (sets.some((s) => s.id === `J${k}`)) k += 1;
|
||||
return `J${k}`;
|
||||
}
|
||||
|
||||
const COLUMNS: LocaleKey[] = [
|
||||
"E06_Col_Name",
|
||||
"E06_Col_From",
|
||||
"E06_Col_To",
|
||||
"E06_Col_DipDir",
|
||||
"E06_Col_Dip",
|
||||
"E06_Col_Phi",
|
||||
"E06_Col_C",
|
||||
"E06_Col_Persist",
|
||||
];
|
||||
|
||||
function jointTable(view: JointsView): HTMLElement {
|
||||
const wrap = el("div", { className: "e06-table-wrap" });
|
||||
const table = el("table", { className: "e06-table" });
|
||||
const head = el("tr");
|
||||
for (const key of COLUMNS) head.append(el("th", { text: t(key) }));
|
||||
head.append(el("th"));
|
||||
table.append(el("thead"), el("tbody"));
|
||||
table.tHead!.append(head);
|
||||
view.doc.sets.forEach((row, k) => {
|
||||
const tr = el("tr", { attrs: { "data-id": row.id } });
|
||||
const name = el("input", {
|
||||
className: "e06-input e06-input--name",
|
||||
attrs: { type: "text", "data-path": `sets.${k}.name` },
|
||||
}) as HTMLInputElement;
|
||||
name.value = row.name;
|
||||
name.disabled = !view.editable;
|
||||
name.addEventListener("input", () => {
|
||||
row.name = name.value;
|
||||
name.classList.toggle("is-invalid", !name.value.trim());
|
||||
view.change();
|
||||
});
|
||||
tr.append(el("td", { children: [name] }));
|
||||
const fields: [keyof JointSet, string, boolean][] = [
|
||||
["from_m", "chainage_m", false],
|
||||
["to_m", "chainage_m", false],
|
||||
["dip_direction", "dip_direction", false],
|
||||
["dip", "dip", false],
|
||||
["phi", "phi", false],
|
||||
["c", "c", false],
|
||||
["persistence_m", "persistence_m", true],
|
||||
];
|
||||
for (const [key, limit, optional] of fields) {
|
||||
const input = numberInput(
|
||||
view,
|
||||
row[key] as number | null,
|
||||
limit,
|
||||
(v) => ((row as unknown as Record<string, number | null>)[key] = v),
|
||||
{ optional, path: `sets.${k}.${key}` },
|
||||
);
|
||||
tr.append(el("td", { children: [input] }));
|
||||
}
|
||||
const remove = createButton({
|
||||
label: "×",
|
||||
variant: "danger-ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
view.doc.sets.splice(k, 1);
|
||||
view.rebuild();
|
||||
},
|
||||
});
|
||||
remove.title = t("E06_Remove");
|
||||
remove.disabled = !view.editable;
|
||||
tr.append(el("td", { children: [remove] }));
|
||||
table.tBodies[0].append(tr);
|
||||
});
|
||||
wrap.append(table);
|
||||
if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement {
|
||||
const row = el("label", { className: "e06-setting" });
|
||||
row.append(el("span", { text: t(label) }), control);
|
||||
return row;
|
||||
}
|
||||
|
||||
function select<T extends string>(
|
||||
view: JointsView,
|
||||
value: T,
|
||||
options: [T, LocaleKey][],
|
||||
set: (v: T) => void,
|
||||
path: string,
|
||||
): HTMLSelectElement {
|
||||
const box = el("select", {
|
||||
className: "e06-input",
|
||||
attrs: { "data-path": path },
|
||||
}) as HTMLSelectElement;
|
||||
for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } }));
|
||||
box.value = value;
|
||||
box.disabled = !view.editable;
|
||||
box.addEventListener("change", () => {
|
||||
set(box.value as T);
|
||||
view.rebuild();
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
export function buildJointCards(view: JointsView): HTMLElement[] {
|
||||
const s = view.doc.settings;
|
||||
const setsCard = createPanelCard(t("E06_Card_Joints"), false);
|
||||
const add = createButton({
|
||||
label: t("E06_Add"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const id = nextId(view.doc.sets);
|
||||
view.doc.sets.push({
|
||||
id,
|
||||
name: id,
|
||||
from_m: 0,
|
||||
to_m: Math.ceil(view.endM),
|
||||
dip_direction: 0,
|
||||
dip: 45,
|
||||
phi: view.defaults.new_set.phi,
|
||||
c: view.defaults.new_set.c,
|
||||
persistence_m: null,
|
||||
});
|
||||
view.rebuild();
|
||||
},
|
||||
});
|
||||
add.disabled = !view.editable;
|
||||
setsCard.append(
|
||||
el("p", { className: "e06-hint", text: t("E06_JointsHint") }),
|
||||
jointTable(view),
|
||||
el("div", { className: "e06-row-actions", children: [add] }),
|
||||
);
|
||||
|
||||
const setCard = createPanelCard(t("E06_Card_Settings"), true);
|
||||
const crack = s.crack;
|
||||
setCard.append(
|
||||
settingRow(
|
||||
"E06_Set_LateralPlanar",
|
||||
numberInput(
|
||||
view,
|
||||
s.lateral_planar_deg,
|
||||
"lateral_deg",
|
||||
(v) => (s.lateral_planar_deg = v ?? NaN),
|
||||
{
|
||||
path: "settings.lateral_planar_deg",
|
||||
},
|
||||
),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_LateralTopple",
|
||||
numberInput(
|
||||
view,
|
||||
s.lateral_toppling_deg,
|
||||
"lateral_deg",
|
||||
(v) => (s.lateral_toppling_deg = v ?? NaN),
|
||||
{ path: "settings.lateral_toppling_deg" },
|
||||
),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_Gamma",
|
||||
numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), {
|
||||
optional: true,
|
||||
path: "settings.gamma_r",
|
||||
}),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_WaterWet",
|
||||
numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), {
|
||||
step: "0.05",
|
||||
path: "settings.water_fill_wet",
|
||||
}),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_Crack",
|
||||
select(
|
||||
view,
|
||||
crack.mode,
|
||||
[
|
||||
["none", "E06_Crack_None"],
|
||||
["critical", "E06_Crack_Critical"],
|
||||
["depth", "E06_Crack_Depth"],
|
||||
],
|
||||
(v) => {
|
||||
crack.mode = v;
|
||||
crack.z = v === "depth" ? (crack.z ?? 1) : null;
|
||||
},
|
||||
"settings.crack.mode",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (crack.mode === "depth")
|
||||
setCard.append(
|
||||
settingRow(
|
||||
"E06_Set_CrackZ",
|
||||
numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }),
|
||||
),
|
||||
);
|
||||
setCard.append(
|
||||
settingRow(
|
||||
"E06_Set_BoltForce",
|
||||
numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), {
|
||||
path: "settings.bolt.force",
|
||||
}),
|
||||
),
|
||||
settingRow(
|
||||
"E06_Set_BoltPlunge",
|
||||
numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), {
|
||||
path: "settings.bolt.plunge",
|
||||
}),
|
||||
),
|
||||
el("p", { className: "e06-hint", text: t("E06_SettingsHint") }),
|
||||
);
|
||||
return [setsCard, setCard];
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Page.ts
|
||||
* E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow).
|
||||
* 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석]
|
||||
* 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도)
|
||||
* 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표
|
||||
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset ·
|
||||
* [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./E06_StabRock_UI_Style.css";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} 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 {
|
||||
buildJointCards,
|
||||
type JointsDoc,
|
||||
type JointsView,
|
||||
type Projection,
|
||||
type RockDefaults,
|
||||
} from "./E06_StabRock_UI_Joints";
|
||||
import {
|
||||
buildBand,
|
||||
buildDetail,
|
||||
buildList,
|
||||
entryId,
|
||||
type RockResult,
|
||||
type StationEntry,
|
||||
} from "./E06_StabRock_UI_Result";
|
||||
import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet";
|
||||
|
||||
interface RockResponse {
|
||||
joints: JointsDoc;
|
||||
saved?: boolean;
|
||||
defaults?: RockDefaults;
|
||||
stations?: { chainage_m: number; label: string; ground_type: string }[];
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
interface ResultResponse {
|
||||
status: string;
|
||||
stale: boolean;
|
||||
result: RockResult | null;
|
||||
}
|
||||
|
||||
interface StationResponse {
|
||||
entry: StationEntry;
|
||||
stereonet: Record<Projection, NetData> & { phi: number };
|
||||
}
|
||||
|
||||
const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`;
|
||||
|
||||
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;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
const errors = (detail as { errors?: unknown[] } | undefined)?.errors;
|
||||
throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function toggle<T extends string>(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) {
|
||||
const group = el("div", { className: "e06-toggle" });
|
||||
const buttons = options.map(([key, v]) => {
|
||||
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
|
||||
button.dataset.value = v;
|
||||
button.classList.toggle("is-on", v === value);
|
||||
button.addEventListener("click", () => {
|
||||
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
|
||||
onPick(v);
|
||||
});
|
||||
return button;
|
||||
});
|
||||
group.append(...buttons);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function renderE06StabRock(root: HTMLElement): void {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const panel = el("div", { className: "ui-sidebar-panel e06-panel" });
|
||||
const main = el("div", { className: "e06-page" });
|
||||
mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel });
|
||||
if (!projectId)
|
||||
return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") }));
|
||||
|
||||
// ---- 좌 ----
|
||||
const cards = el("div", { className: "e06-cards" });
|
||||
const status = el("p", { className: "e06-status" });
|
||||
const saveButton = createButton({
|
||||
label: t("Common_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void save(),
|
||||
});
|
||||
const resetButton = createButton({
|
||||
label: t("Common_Btn_Reset"),
|
||||
variant: "danger-ghost",
|
||||
onClick: () => void reset(),
|
||||
});
|
||||
const analyzeButton = createButton({
|
||||
label: t("E06_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void analyze(),
|
||||
});
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(resetButton, saveButton, analyzeButton);
|
||||
panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions);
|
||||
attachCollapsible(cards);
|
||||
|
||||
// ---- 가운데 · 우 ----
|
||||
const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") });
|
||||
staleBar.hidden = true;
|
||||
const bandSlot = el("div");
|
||||
const title = el("strong", { className: "e06-title" });
|
||||
const netSlot = el("div", { className: "e06-net-slot" });
|
||||
const legendSlot = el("div");
|
||||
let projection: Projection = "equal_angle";
|
||||
let netMode: NetMode = "all";
|
||||
const toolbar = el("div", { className: "e06-toolbar" });
|
||||
const projectionToggle = el("div");
|
||||
toolbar.append(
|
||||
title,
|
||||
projectionToggle,
|
||||
toggle<NetMode>(
|
||||
[
|
||||
["E06_View_All", "all"],
|
||||
["E06_Mode_planar", "planar"],
|
||||
["E06_Mode_wedge", "wedge"],
|
||||
["E06_Mode_toppling", "toppling"],
|
||||
],
|
||||
netMode,
|
||||
(v) => {
|
||||
netMode = v;
|
||||
paintNet();
|
||||
},
|
||||
),
|
||||
);
|
||||
const stage = el("div", { className: "e06-stage" });
|
||||
stage.append(netSlot, legendSlot);
|
||||
const center = el("div", { className: "e06-center" });
|
||||
center.append(staleBar, bandSlot, toolbar, stage);
|
||||
const listSlot = el("div", { className: "e06-list-slot" });
|
||||
const detailSlot = el("div");
|
||||
const sideBody = el("div", { className: "e06-side" });
|
||||
sideBody.append(listSlot, detailSlot);
|
||||
const side = createSidePanels({
|
||||
prefKey: "e06-stab-rock-result",
|
||||
panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }],
|
||||
defaultOpen: "result",
|
||||
defaultWidth: 380,
|
||||
follow: false,
|
||||
});
|
||||
const row = el("div", { className: "ui-side-panels-row e06-row" });
|
||||
row.append(center, side.root);
|
||||
main.append(row);
|
||||
|
||||
// ---- 상태 ----
|
||||
let view: JointsView | null = null;
|
||||
let dirty = false;
|
||||
let result: RockResult | null = null;
|
||||
let active: string | null = null;
|
||||
let shown: StationResponse | null = null;
|
||||
|
||||
const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length;
|
||||
const paintStatus = (): void => {
|
||||
const lines: string[] = [];
|
||||
if (view && !view.editable) lines.push(t("E06_ReadOnly"));
|
||||
if (dirty) lines.push(t("E06_Unsaved"));
|
||||
const bad = invalidCount();
|
||||
if (bad) lines.push(`${t("E06_Invalid")} ${bad}`);
|
||||
status.textContent = lines.join("\n");
|
||||
status.classList.toggle("is-error", bad > 0);
|
||||
};
|
||||
|
||||
const renderCards = (): void => {
|
||||
if (!view) return;
|
||||
// 다시 그려도 접힘은 그대로
|
||||
const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed"));
|
||||
const built = buildJointCards(view);
|
||||
built.forEach(
|
||||
(c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]),
|
||||
);
|
||||
cards.replaceChildren(...built);
|
||||
paintStatus();
|
||||
};
|
||||
|
||||
const remember = (): void => {
|
||||
if (!view) return;
|
||||
dirty = true;
|
||||
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc));
|
||||
};
|
||||
|
||||
function paintProjection(): void {
|
||||
projectionToggle.replaceChildren(
|
||||
toggle<Projection>(
|
||||
[
|
||||
["E06_Proj_Angle", "equal_angle"],
|
||||
["E06_Proj_Area", "equal_area"],
|
||||
],
|
||||
projection,
|
||||
(v) => {
|
||||
projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감)
|
||||
paintNet();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function paintNet(): void {
|
||||
legendSlot.replaceChildren(netLegend(netMode));
|
||||
if (!shown) {
|
||||
title.textContent = "";
|
||||
netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") }));
|
||||
return;
|
||||
}
|
||||
const e = shown.entry;
|
||||
title.textContent = `${e.label ?? e.key} · ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} · φ ${shown.stereonet.phi}°`;
|
||||
netSlot.replaceChildren(
|
||||
drawStereonet(shown.stereonet[projection], netMode, {
|
||||
planar: e.kinematic.planar,
|
||||
toppling: e.kinematic.toppling,
|
||||
wedge: e.kinematic.wedge.map((w) => w.ids.join("|")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function paintResult(): void {
|
||||
const stations = result?.stations ?? [];
|
||||
bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id)));
|
||||
listSlot.replaceChildren(
|
||||
stations.length
|
||||
? buildList(stations, active, (id) => void select(id))
|
||||
: el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }),
|
||||
);
|
||||
detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span"));
|
||||
paintNet();
|
||||
}
|
||||
|
||||
async function select(id: string): Promise<void> {
|
||||
active = id;
|
||||
const [key, sideName] = id.split("|");
|
||||
try {
|
||||
shown = await api<StationResponse>(
|
||||
`/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`,
|
||||
);
|
||||
} catch (error) {
|
||||
shown = null;
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
paintResult();
|
||||
}
|
||||
|
||||
async function loadResult(): Promise<void> {
|
||||
const body = await api<ResultResponse>(`/projects/${projectId}/stab/rock/result`);
|
||||
result = body.result;
|
||||
staleBar.hidden = !body.stale;
|
||||
const stations = result?.stations ?? [];
|
||||
const keep = stations.find((e) => entryId(e) === active);
|
||||
const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0];
|
||||
if (first) await select(entryId(first));
|
||||
else {
|
||||
active = null;
|
||||
shown = null;
|
||||
paintResult();
|
||||
}
|
||||
}
|
||||
|
||||
const adopt = (body: RockResponse, keepCache: boolean): void => {
|
||||
if (!view) return;
|
||||
const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
|
||||
if (!cached) sessionStorage.removeItem(cacheKey(projectId));
|
||||
view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints;
|
||||
dirty = Boolean(cached);
|
||||
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable;
|
||||
renderCards();
|
||||
};
|
||||
|
||||
async function putJoints(): Promise<boolean> {
|
||||
if (!view?.editable) return false;
|
||||
if (invalidCount()) {
|
||||
showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error");
|
||||
return false;
|
||||
}
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ joints: view.doc }),
|
||||
});
|
||||
adopt(body, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
if (await putJoints()) {
|
||||
showToast(t("E06_Saved"), "success");
|
||||
await loadResult();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
const ok = await showConfirmDialog(
|
||||
t("E06_ResetConfirm"),
|
||||
t("Common_Btn_Reset"),
|
||||
t("Common_Btn_Cancel"),
|
||||
);
|
||||
if (!ok) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock/reset`, {
|
||||
method: "POST",
|
||||
});
|
||||
adopt(body, false);
|
||||
showToast(t("E06_ResetDone"), "success");
|
||||
await loadResult();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze(): Promise<void> {
|
||||
if (!view?.editable) return;
|
||||
analyzeButton.disabled = true;
|
||||
status.textContent = t("E06_Running");
|
||||
try {
|
||||
if (dirty && !(await putJoints())) return;
|
||||
const body = await api<{ status: string; summary?: RockResult["summary"] }>(
|
||||
`/projects/${projectId}/stab/rock/analyze`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
await loadResult();
|
||||
showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
analyzeButton.disabled = !view.editable;
|
||||
paintStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`);
|
||||
const stations = body.stations ?? [];
|
||||
projection = body.joints.settings.projection;
|
||||
paintProjection();
|
||||
view = {
|
||||
doc: body.joints,
|
||||
defaults: body.defaults as RockDefaults,
|
||||
editable: body.editable ?? false,
|
||||
endM: stations.length ? stations[stations.length - 1].chainage_m : 0,
|
||||
change: () => {
|
||||
remember();
|
||||
paintStatus();
|
||||
},
|
||||
rebuild: () => {
|
||||
remember();
|
||||
renderCards();
|
||||
},
|
||||
};
|
||||
adopt(body, true);
|
||||
await loadResult();
|
||||
} catch (error) {
|
||||
main.replaceChildren(
|
||||
el("p", {
|
||||
className: "e06-empty",
|
||||
text: error instanceof Error ? error.message : t("Common_Status_Error"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Result.ts
|
||||
* E06 결과 — 위험 띠(누가 축 · 측점 판정 색 · 누르면 그 측점) · 우측 측점 목록(쪽 · 모드 수 ·
|
||||
* 최소 안전율 · 판정) · 고른 측점 사면 · 모드별 안전율 표. 값은 서버 저장본 그대로(계산 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
|
||||
export type Verdict = "NG" | "check" | "OK" | "none";
|
||||
|
||||
export interface RockCase {
|
||||
mode: "planar" | "wedge";
|
||||
ids: string[];
|
||||
condition: "dry" | "wet" | "seismic";
|
||||
F: number | null;
|
||||
formed: boolean;
|
||||
persistence_short: boolean;
|
||||
required: number | null;
|
||||
ok: boolean | null;
|
||||
sliding?: string | null;
|
||||
}
|
||||
|
||||
export interface StationEntry {
|
||||
key: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
ground_type: string;
|
||||
slope: { side: string; dip_direction: number; dip: number; height_m: number; upper_dip: number };
|
||||
gamma_r: number;
|
||||
joints: {
|
||||
id: string;
|
||||
name: string;
|
||||
dip_direction: number;
|
||||
dip: number;
|
||||
phi: number;
|
||||
c: number;
|
||||
}[];
|
||||
kinematic: {
|
||||
planar: string[];
|
||||
toppling: string[];
|
||||
wedge: { ids: [string, string]; trend: number; plunge: number }[];
|
||||
};
|
||||
cases: RockCase[];
|
||||
counts: { planar: number; wedge: number; toppling: number };
|
||||
min_F: number | null;
|
||||
verdict: Verdict;
|
||||
}
|
||||
|
||||
export interface RockResult {
|
||||
fingerprint: string;
|
||||
summary: {
|
||||
slopes: number;
|
||||
verdicts: Partial<Record<Verdict, number>>;
|
||||
modes: Record<string, number>;
|
||||
};
|
||||
stations: StationEntry[];
|
||||
}
|
||||
|
||||
const ORDER: Verdict[] = ["NG", "check", "OK", "none"];
|
||||
export const entryId = (e: StationEntry): string => `${e.key}|${e.slope.side}`;
|
||||
const fmt = (v: number | null | undefined, d = 2): string =>
|
||||
v === null || v === undefined ? "–" : v.toFixed(d);
|
||||
|
||||
function badge(verdict: Verdict): HTMLElement {
|
||||
return el("span", {
|
||||
className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`,
|
||||
text: t(`E06_V_${verdict}` as LocaleKey),
|
||||
});
|
||||
}
|
||||
|
||||
/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */
|
||||
export function buildBand(
|
||||
stations: StationEntry[],
|
||||
active: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
): HTMLElement {
|
||||
const box = el("div", { className: "e06-band", attrs: { "data-role": "band" } });
|
||||
const worst = new Map<number, StationEntry>();
|
||||
for (const e of stations) {
|
||||
const prev = worst.get(e.chainage_m);
|
||||
if (!prev || ORDER.indexOf(e.verdict) < ORDER.indexOf(prev.verdict)) worst.set(e.chainage_m, e);
|
||||
}
|
||||
const list = [...worst.values()].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const legend = el("div", { className: "e06-band__legend" });
|
||||
legend.append(el("strong", { text: t("E06_Band") }));
|
||||
for (const v of ORDER) {
|
||||
const n = list.filter((e) => e.verdict === v).length;
|
||||
if (n) legend.append(badge(v), el("span", { className: "e06-band__count", text: String(n) }));
|
||||
}
|
||||
const strip = el("div", { className: "e06-band__strip" });
|
||||
if (list.length) {
|
||||
const lo = list[0].chainage_m;
|
||||
const hi = Math.max(list[list.length - 1].chainage_m, lo + 1);
|
||||
list.forEach((e, i) => {
|
||||
const a = i ? (list[i - 1].chainage_m + e.chainage_m) / 2 : lo;
|
||||
const b = i < list.length - 1 ? (e.chainage_m + list[i + 1].chainage_m) / 2 : hi;
|
||||
const seg = el("button", {
|
||||
className: `e06-band__seg e06-band__seg--${e.verdict.toLowerCase()}${
|
||||
active?.startsWith(`${e.key}|`) ? " is-active" : ""
|
||||
}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-key": e.key,
|
||||
title: `${e.label ?? e.key} · ${t(`E06_V_${e.verdict}` as LocaleKey)}`,
|
||||
},
|
||||
});
|
||||
seg.style.left = `${((a - lo) / (hi - lo)) * 100}%`;
|
||||
seg.style.width = `${Math.max(0.4, ((b - a) / (hi - lo)) * 100)}%`;
|
||||
seg.addEventListener("click", () => onSelect(entryId(e)));
|
||||
strip.append(seg);
|
||||
});
|
||||
const ticks = el("div", { className: "e06-band__ticks" });
|
||||
ticks.append(
|
||||
el("span", { text: `${lo.toFixed(0)} m` }),
|
||||
el("span", { text: `${hi.toFixed(0)} m` }),
|
||||
);
|
||||
box.append(legend, strip, ticks);
|
||||
} else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") }));
|
||||
return box;
|
||||
}
|
||||
|
||||
export function buildList(
|
||||
stations: StationEntry[],
|
||||
active: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
): HTMLElement {
|
||||
const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } });
|
||||
const head = el("tr");
|
||||
for (const key of [
|
||||
"E06_L_Station",
|
||||
"E06_L_Side",
|
||||
"E06_L_Modes",
|
||||
"E06_L_MinF",
|
||||
"E06_L_Verdict",
|
||||
] as LocaleKey[])
|
||||
head.append(el("th", { text: t(key) }));
|
||||
table.append(el("thead", { children: [head] }), el("tbody"));
|
||||
for (const e of stations) {
|
||||
const id = entryId(e);
|
||||
const tr = el("tr", {
|
||||
className: id === active ? "is-active" : "",
|
||||
attrs: { "data-id": id, "data-verdict": e.verdict },
|
||||
});
|
||||
tr.append(
|
||||
el("td", { text: e.label ?? e.key }),
|
||||
el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }),
|
||||
el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }),
|
||||
el("td", { text: fmt(e.min_F) }),
|
||||
el("td", { children: [badge(e.verdict)] }),
|
||||
);
|
||||
tr.addEventListener("click", () => onSelect(id));
|
||||
table.tBodies[0].append(tr);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
export function buildDetail(e: StationEntry): HTMLElement {
|
||||
const box = el("div", { className: "e06-detail", attrs: { "data-role": "detail" } });
|
||||
const s = e.slope;
|
||||
box.append(
|
||||
el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }),
|
||||
el("p", {
|
||||
className: "e06-hint",
|
||||
text: t("E06_SlopeLine")
|
||||
.replace("{dd}", s.dip_direction.toFixed(0))
|
||||
.replace("{dip}", s.dip.toFixed(1))
|
||||
.replace("{h}", s.height_m.toFixed(2))
|
||||
.replace("{up}", s.upper_dip.toFixed(1))
|
||||
.replace("{g}", e.gamma_r.toFixed(1)),
|
||||
}),
|
||||
);
|
||||
const kin = e.kinematic;
|
||||
const kinText = [
|
||||
`${t("E06_Mode_planar")} ${kin.planar.join(", ") || "–"}`,
|
||||
`${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`,
|
||||
`${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`,
|
||||
];
|
||||
box.append(el("p", { className: "e06-kin", text: kinText.join("\n") }));
|
||||
if (!e.cases.length) {
|
||||
box.append(
|
||||
el("p", {
|
||||
className: "e06-hint",
|
||||
text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"),
|
||||
}),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
const table = el("table", { className: "e06-cases" });
|
||||
const head = el("tr");
|
||||
for (const key of [
|
||||
"E06_C_Mode",
|
||||
"E06_C_Joints",
|
||||
"E06_C_Cond",
|
||||
"E06_C_F",
|
||||
"E06_C_Req",
|
||||
"E06_L_Verdict",
|
||||
] as LocaleKey[])
|
||||
head.append(el("th", { text: t(key) }));
|
||||
table.append(el("thead", { children: [head] }), el("tbody"));
|
||||
for (const c of e.cases) {
|
||||
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
|
||||
const note = !c.formed ? t("E06_NotFormed") : c.persistence_short ? t("E06_Persist") : "";
|
||||
const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } });
|
||||
tr.append(
|
||||
el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }),
|
||||
el("td", { text: c.ids.join("·") }),
|
||||
el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }),
|
||||
el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }),
|
||||
el("td", { text: fmt(c.required) }),
|
||||
el("td", { children: [badge(verdict)] }),
|
||||
);
|
||||
table.tBodies[0].append(tr);
|
||||
}
|
||||
box.append(table);
|
||||
if (kin.toppling.length)
|
||||
box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") }));
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Stereonet.ts
|
||||
* E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를
|
||||
* 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도.
|
||||
* 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 ·
|
||||
* 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const SIZE = 440;
|
||||
const R = 190;
|
||||
const C = SIZE / 2;
|
||||
|
||||
export type NetMode = "all" | "planar" | "wedge" | "toppling";
|
||||
type XY = [number, number];
|
||||
|
||||
export interface NetData {
|
||||
projection: string;
|
||||
slope: { pole: XY; great_circle: XY[] };
|
||||
planes: { id: string; pole: XY; great_circle: XY[] }[];
|
||||
intersections: { ids: [string, string]; point: XY }[];
|
||||
friction_pole_r: number;
|
||||
friction_line_r: number;
|
||||
daylight_envelope: XY[];
|
||||
zones: Record<"planar" | "wedge" | "toppling", XY[] | null>;
|
||||
}
|
||||
|
||||
export interface NetMarks {
|
||||
planar: string[];
|
||||
toppling: string[];
|
||||
wedge: string[]; // "J1|J2"
|
||||
}
|
||||
|
||||
function svg<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number> = {},
|
||||
text?: string,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const node = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
const sx = (p: XY): number => C + R * p[0];
|
||||
const sy = (p: XY): number => C - R * p[1];
|
||||
const path = (pts: XY[], close = false): string =>
|
||||
pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") +
|
||||
(close ? "Z" : "");
|
||||
|
||||
/** 절리 색 번호(0~5) — 표 · 그림 같은 차례 */
|
||||
export const jointTone = (index: number): number => index % 6;
|
||||
|
||||
export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement {
|
||||
const root = svg("svg", {
|
||||
viewBox: `0 0 ${SIZE} ${SIZE}`,
|
||||
class: "e06-net",
|
||||
"data-role": "stereonet",
|
||||
"data-projection": net.projection,
|
||||
"data-mode": mode,
|
||||
});
|
||||
const clip = svg("clipPath", { id: "e06-net-clip" });
|
||||
clip.append(svg("circle", { cx: C, cy: C, r: R }));
|
||||
const defs = svg("defs");
|
||||
defs.append(clip);
|
||||
root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" }));
|
||||
const body = svg("g", { "clip-path": "url(#e06-net-clip)" });
|
||||
root.append(body);
|
||||
|
||||
const show = (m: Exclude<NetMode, "all">): boolean => mode === "all" || mode === m;
|
||||
for (const m of ["planar", "wedge", "toppling"] as const) {
|
||||
const zone = net.zones[m];
|
||||
if (zone && show(m))
|
||||
body.append(
|
||||
svg("path", {
|
||||
d: path(zone, true),
|
||||
class: `e06-net__zone e06-net__zone--${m}`,
|
||||
"data-zone": m,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 마찰원
|
||||
if (show("planar") || show("toppling"))
|
||||
body.append(
|
||||
svg("circle", {
|
||||
cx: C,
|
||||
cy: C,
|
||||
r: R * net.friction_pole_r,
|
||||
class: "e06-net__friction",
|
||||
"data-role": "friction-pole",
|
||||
}),
|
||||
);
|
||||
if (show("wedge"))
|
||||
body.append(
|
||||
svg("circle", {
|
||||
cx: C,
|
||||
cy: C,
|
||||
r: R * net.friction_line_r,
|
||||
class: "e06-net__friction e06-net__friction--line",
|
||||
"data-role": "friction-line",
|
||||
}),
|
||||
);
|
||||
if (show("planar"))
|
||||
body.append(
|
||||
svg("path", {
|
||||
d: path(net.daylight_envelope),
|
||||
class: "e06-net__envelope",
|
||||
"data-role": "daylight",
|
||||
}),
|
||||
);
|
||||
// 대원
|
||||
net.planes.forEach((p, i) =>
|
||||
body.append(
|
||||
svg("path", {
|
||||
d: path(p.great_circle),
|
||||
class: `e06-net__gc e06-tone-${jointTone(i)}`,
|
||||
"data-id": p.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
body.append(
|
||||
svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }),
|
||||
);
|
||||
|
||||
// 극점(평면 · 전도 보기) · 교선(쐐기 보기)
|
||||
if (mode !== "wedge")
|
||||
net.planes.forEach((p, i) => {
|
||||
const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id);
|
||||
const g = svg("g", {
|
||||
class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`,
|
||||
"data-pole": p.id,
|
||||
});
|
||||
g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 }));
|
||||
g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id));
|
||||
root.append(g);
|
||||
});
|
||||
if (mode === "all" || mode === "wedge")
|
||||
for (const line of net.intersections) {
|
||||
const key = line.ids.join("|");
|
||||
const risky = marks.wedge.includes(key);
|
||||
const [x, y] = [sx(line.point), sy(line.point)];
|
||||
const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key });
|
||||
g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` }));
|
||||
g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("·")));
|
||||
root.append(g);
|
||||
}
|
||||
// 사면 극점 · 방위
|
||||
root.append(
|
||||
svg("rect", {
|
||||
x: sx(net.slope.pole) - 4,
|
||||
y: sy(net.slope.pole) - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
class: "e06-net__slope-pole",
|
||||
}),
|
||||
);
|
||||
for (const [label, x, y] of [
|
||||
["N", C, C - R - 8],
|
||||
["E", C + R + 10, C + 4],
|
||||
["S", C, C + R + 18],
|
||||
["W", C - R - 10, C + 4],
|
||||
] as const)
|
||||
root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label));
|
||||
root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" }));
|
||||
root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" }));
|
||||
return root;
|
||||
}
|
||||
|
||||
export function netLegend(mode: NetMode): HTMLElement {
|
||||
const box = el("div", { className: "e06-legend" });
|
||||
const items: [string, LocaleKey][] = [
|
||||
["slope", "E06_Lg_Slope"],
|
||||
["friction", "E06_Lg_Friction"],
|
||||
];
|
||||
if (mode === "all" || mode === "planar")
|
||||
items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]);
|
||||
if (mode === "all" || mode === "wedge")
|
||||
items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]);
|
||||
if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]);
|
||||
if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]);
|
||||
for (const [cls, key] of items) {
|
||||
const row = el("span", { className: "e06-legend__row" });
|
||||
row.append(
|
||||
el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }),
|
||||
el("span", { text: t(key) }),
|
||||
);
|
||||
box.append(row);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_UI_Style.css
|
||||
* E06 암반 해석 — 좌측 절리 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우측 측점 결과
|
||||
* 색은 테마 토큰(밝음 · 어두움 같이) · 판정 · 모드 · 절리 색만 고정색(E03 판정색과 같음)
|
||||
* ========================================================================== */
|
||||
|
||||
.e06-page {
|
||||
--e06-ng: #d03b3b;
|
||||
--e06-ok: #0ca30c;
|
||||
--e06-check: #e0a106;
|
||||
--e06-none: #9a9a95;
|
||||
--e06-planar: #d03b3b;
|
||||
--e06-wedge: #f1963b;
|
||||
--e06-toppling: #9b6dde;
|
||||
height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.e06-row {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.e06-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;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e06-empty,
|
||||
.e06-hint,
|
||||
.e06-status,
|
||||
.e06-kin {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.e06-empty {
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.e06-status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.e06-kin {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-panel .ui-sidebar-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e06-stale {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-stale[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- 좌측 표 · 설정 ---- */
|
||||
.e06-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.e06-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.e06-table th {
|
||||
padding: 2px 3px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e06-table td {
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
.e06-input {
|
||||
width: 4.2em;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.e06-input--name {
|
||||
width: 3.6em;
|
||||
}
|
||||
|
||||
select.e06-input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.e06-input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface));
|
||||
}
|
||||
|
||||
.e06-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.e06-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
margin: 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-setting .e06-input {
|
||||
width: 5.5em;
|
||||
}
|
||||
|
||||
/* ---- 가운데 ---- */
|
||||
.e06-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e06-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-toggle {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.e06-toggle .ui-btn.is-on {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.e06-stage {
|
||||
display: flex;
|
||||
gap: var(--spacing-12);
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.e06-net-slot {
|
||||
flex: 1 1 360px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.e06-net {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.e06-net__rim {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-text);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.e06-net__zone {
|
||||
stroke: none;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.e06-net__zone--planar {
|
||||
fill: var(--e06-planar);
|
||||
}
|
||||
|
||||
.e06-net__zone--wedge {
|
||||
fill: var(--e06-wedge);
|
||||
}
|
||||
|
||||
.e06-net__zone--toppling {
|
||||
fill: var(--e06-toppling);
|
||||
}
|
||||
|
||||
.e06-net__friction {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
.e06-net__friction--line {
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
|
||||
.e06-net__envelope {
|
||||
fill: none;
|
||||
stroke: var(--e06-planar);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 6 3;
|
||||
}
|
||||
|
||||
.e06-net__gc {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.e06-net__slope {
|
||||
fill: none;
|
||||
stroke: var(--color-text);
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.e06-net__slope-pole {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-net__pole circle {
|
||||
fill: currentColor;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.e06-net__pole.is-risk circle,
|
||||
.e06-net__line.is-risk path {
|
||||
stroke: var(--e06-ng);
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.e06-net__pole text,
|
||||
.e06-net__line text,
|
||||
.e06-net__dir {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-net__line path {
|
||||
fill: var(--e06-wedge);
|
||||
stroke: var(--color-surface);
|
||||
}
|
||||
|
||||
.e06-net__tick {
|
||||
stroke: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e06-tone-0 {
|
||||
color: #2f6fd6;
|
||||
}
|
||||
.e06-tone-1 {
|
||||
color: #1f9d6a;
|
||||
}
|
||||
.e06-tone-2 {
|
||||
color: #c2410c;
|
||||
}
|
||||
.e06-tone-3 {
|
||||
color: #7c4dcc;
|
||||
}
|
||||
.e06-tone-4 {
|
||||
color: #b8860b;
|
||||
}
|
||||
.e06-tone-5 {
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.e06-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-legend__row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.e06-legend__mark {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.e06-legend__mark--slope {
|
||||
height: 3px;
|
||||
background: var(--color-text);
|
||||
}
|
||||
.e06-legend__mark--friction {
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--color-text-secondary);
|
||||
}
|
||||
.e06-legend__mark--daylight {
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--e06-planar);
|
||||
}
|
||||
.e06-legend__mark--planar {
|
||||
background: color-mix(in srgb, var(--e06-planar) 35%, transparent);
|
||||
}
|
||||
.e06-legend__mark--wedge {
|
||||
background: color-mix(in srgb, var(--e06-wedge) 35%, transparent);
|
||||
}
|
||||
.e06-legend__mark--toppling {
|
||||
background: color-mix(in srgb, var(--e06-toppling) 35%, transparent);
|
||||
}
|
||||
.e06-legend__mark--line {
|
||||
width: 10px;
|
||||
background: var(--e06-wedge);
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
.e06-legend__mark--pole {
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
background: #2f6fd6;
|
||||
}
|
||||
|
||||
/* ---- 위험 띠 ---- */
|
||||
.e06-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.e06-band__legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-band__count {
|
||||
margin-right: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.e06-band__strip {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.e06-band__seg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e06-band__seg--ng {
|
||||
background: var(--e06-ng);
|
||||
}
|
||||
.e06-band__seg--ok {
|
||||
background: var(--e06-ok);
|
||||
}
|
||||
.e06-band__seg--check {
|
||||
background: var(--e06-check);
|
||||
}
|
||||
.e06-band__seg--none {
|
||||
background: var(--e06-none);
|
||||
}
|
||||
|
||||
.e06-band__seg.is-active {
|
||||
outline: 2px solid var(--color-text);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.e06-band__ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- 우측 ---- */
|
||||
.e06-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.e06-list-slot {
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.e06-list,
|
||||
.e06-cases {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-list th,
|
||||
.e06-cases th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 3px 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.e06-list td,
|
||||
.e06-cases td {
|
||||
padding: 3px 4px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.e06-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.e06-list tbody tr:hover {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.e06-list tr.is-active {
|
||||
background: color-mix(in srgb, var(--color-primary) 16%, transparent);
|
||||
}
|
||||
|
||||
.e06-detail h4 {
|
||||
margin: 0 0 4px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.e06-verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e06-verdict::before {
|
||||
content: "";
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--e06-none);
|
||||
}
|
||||
|
||||
.e06-verdict--ng::before {
|
||||
background: var(--e06-ng);
|
||||
border-radius: 2px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.e06-verdict--ok::before {
|
||||
background: var(--e06-ok);
|
||||
}
|
||||
.e06-verdict--check::before {
|
||||
background: var(--e06-check);
|
||||
border-radius: 0;
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* =============================================================================
|
||||
* E06_StabRock_Route.ts
|
||||
* 길 선언(PLAN 536-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E06 암반 평사투영 페이지
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E06_STAB_ROCK,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () => (await import("../E06_StabRock_UI_Page")).renderE06StabRock,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,7 +20,14 @@ from config.config_db import get_db_pool
|
||||
|
||||
KIND_STAGE_DIRS = {
|
||||
"PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"),
|
||||
"STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"),
|
||||
"STABILITY": (
|
||||
"E02_StabGround",
|
||||
"E03_StabSection",
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
"E06_StabRock",
|
||||
"E07_StabArea",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ STAGE_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05.
|
||||
# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E06.
|
||||
STAGE_KEYS_BY_KIND = {
|
||||
"ROAD": STAGE_KEYS,
|
||||
"PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"],
|
||||
"STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"],
|
||||
"STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT", "STAB_ROCK"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@ export const ROUTES = {
|
||||
E03_STAB_SECTION: "e03-stab-section",
|
||||
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 은 단계 밖) */
|
||||
@@ -139,12 +141,14 @@ export const PLAN_STEP_ROUTES = [
|
||||
ROUTES.D04_PLAN_CONFIRM,
|
||||
] as const;
|
||||
|
||||
/** E 안정성평가 단계 주소(E02 → E05 · 생성 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;
|
||||
|
||||
@@ -220,6 +220,8 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset(
|
||||
"E03_StabSection",
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
"E06_StabRock",
|
||||
"E07_StabArea",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -141,3 +141,24 @@ PDF 무료 라이브러리 중에 사용 가능 한것중에 품질이 좋은
|
||||
|
||||
너 브라우져를 반복적으로 띄우고 있다고
|
||||
벌써 2번째야
|
||||
|
||||
## 요구 354 (10-25 · 사용자)
|
||||
|
||||
오느카 브라우져는 벌써 기능이있거나 스크립트로 운용하는게 좋아보임
|
||||
|
||||
## 요구 355 (10-25 · 사용자)
|
||||
|
||||
클로드 5시간 토큰 사용 제한에 걸릴것으로 예상됨. 토큰 풀린 후 바로 너를 깨워주는 스크립트가 있는지 확인.
|
||||
|
||||
1시간에 한번이면 됨
|
||||
|
||||
## 요구 356 (10-25 · 사용자)
|
||||
|
||||
토크 한도 리밋 관련 스크립트 실행은 내가 on/off는 내가 지시하겠음. 현재는 o. 메모에서 삭제.
|
||||
|
||||
## 요구 357 (10-25 · 사용자)
|
||||
|
||||
on 이 맞음. 그런데 shell command가 아님?
|
||||
쉘로 바꿔줘 뭐가 실행중인지 오르카 ide에 표시되는게 좋겠어
|
||||
|
||||
그말이 아니라 지금 너와의 대화창에는 감시 스크립트가 돌면 wait for worker reports 라고 shell command 항목이 보여 이걸로 실행여부를 판단하고 싶다고
|
||||
|
||||
@@ -83,7 +83,9 @@ 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
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
@@ -493,7 +495,9 @@ 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)
|
||||
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_terrain_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 이상)이 있는 측점.
|
||||
|
||||
@@ -165,7 +165,7 @@ def test_e01_requires_b06(monkeypatch):
|
||||
call()
|
||||
assert e.value.status_code == 404
|
||||
seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}])
|
||||
assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40}
|
||||
assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40, "warnings": []}
|
||||
assert seen["copied"] == ("stab-id", "s" * 36)
|
||||
assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36
|
||||
assert seen["name"] == "임도 안정성평가"
|
||||
@@ -257,7 +257,7 @@ def test_register_ignores_sent_company():
|
||||
assert "company_name" not in data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)])
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 5)])
|
||||
def test_stage_seed_by_kind(kind, count):
|
||||
log = []
|
||||
asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind))
|
||||
|
||||
@@ -83,16 +83,6 @@ def root(tmp_path, monkeypatch):
|
||||
z = _ground(yy)
|
||||
np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool))
|
||||
monkeypatch.setattr(run.source, "cross_section", lambda r, ch: _cross(float(ch)))
|
||||
monkeypatch.setattr(
|
||||
run.source,
|
||||
"db_snapshot",
|
||||
lambda r: {
|
||||
"route_points": [
|
||||
{"model_x": ch, "model_y": 0.0, "chainage_m": ch, "sequence_num": i}
|
||||
for i, ch in enumerate(CHAINAGES)
|
||||
]
|
||||
},
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@@ -178,3 +168,17 @@ def test_hooks_do_not_change_search():
|
||||
with pytest.raises(InterruptedError):
|
||||
search(strata, SearchParams(**base, should_stop=lambda: True))
|
||||
assert math.isfinite(plain.best["governing"])
|
||||
|
||||
|
||||
def test_node_mask_section_and_route_ends(root):
|
||||
"""노드 자리 = 구간 누가 안 · 탐색 폭 안 · 노선 시점 앞(x < 0) · 종점 뒤(x > 100)는 뺌."""
|
||||
x = np.arange(-30.0, 131.0, 1.0)
|
||||
y = np.arange(-20.0, 21.0, 1.0)
|
||||
grid = {"x": x, "y": y}
|
||||
mask = run._node_mask(STATIONS, grid, 0.0, 100.0, 12.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
assert not mask[xx < -0.5].any() and not mask[xx > 100.5].any()
|
||||
assert not mask[np.abs(yy) > 12.0].any()
|
||||
assert mask[(np.abs(yy) <= 12.0) & (xx >= 1) & (xx <= 99)].all()
|
||||
part = run._node_mask(STATIONS, grid, 20.0, 60.0, 12.0)
|
||||
assert not part[(xx < 19.5) | (xx > 60.5)].any() and part[(yy == 0) & (xx == 40)].all()
|
||||
|
||||
@@ -60,6 +60,8 @@ writeFileSync(process.argv[3], JSON.stringify({
|
||||
sphere: mesh(M.slipMesh(c.grid, c.z, c.sphere)),
|
||||
ellipsoid: mesh(M.slipMesh(c.grid, c.z, c.ellipsoid)),
|
||||
cut: Array.from(M.cutCells(c.grid, c.z, c.sphere).keys()),
|
||||
center: M.footprintCenter(c.grid, M.cutCells(c.grid, c.z, c.sphere), c.sphere),
|
||||
centerEmpty: M.footprintCenter(c.grid, new Map(), c.sphere),
|
||||
floored: Object.fromEntries(M.cutCells(c.grid, c.z,
|
||||
{ ...c.sphere, floor: [[7 * 21 + 10, 53.0], [7 * 21 + 11, 60.0]] }).entries()),
|
||||
holed: mesh(M.terrainWithout(c.grid, c.z, M.cutCells(c.grid, c.z, c.sphere))),
|
||||
@@ -180,6 +182,15 @@ def test_slip_mesh_vertices(ts, name):
|
||||
assert all(z < Z[i] for i, z in cut.items())
|
||||
|
||||
|
||||
def test_footprint_center(ts):
|
||||
"""지도 최소 표시 = 잘린 셀 중심 평균 · 잘린 셀 없으면 구 중심."""
|
||||
cut = [i for i, _ in _py_cut(SPHERE)]
|
||||
x = sum(GRID["x0"] + i % GRID["nx"] for i in cut) / len(cut)
|
||||
y = sum(GRID["y0"] + i // GRID["nx"] for i in cut) / len(cut)
|
||||
assert ts["center"] == pytest.approx({"x": x, "y": y})
|
||||
assert ts["centerEmpty"] == {"x": SPHERE["x"], "y": SPHERE["y"]}
|
||||
|
||||
|
||||
def test_floor_truncates_slip(ts):
|
||||
"""못 뚫는 층 — 활동면이 층 윗면 아래로 안 내려감 · 층이 지표보다 높으면 잘린 셀 아님."""
|
||||
center = 7 * 21 + 10
|
||||
|
||||
@@ -57,21 +57,6 @@ def project(tmp_path, monkeypatch):
|
||||
z = 500.0 + 0.4 * (yy - 200.0) + 0.002 * (xx - 200.0) ** 2 # 노선 옆 비탈
|
||||
np.savez(models / "dtm_classification.npz", x=xs, y=ys, z=z, valid_mask=np.ones_like(z, bool))
|
||||
monkeypatch.setattr(run.source, "stations", lambda root: STATIONS)
|
||||
monkeypatch.setattr(
|
||||
run.source,
|
||||
"db_snapshot",
|
||||
lambda root: {
|
||||
"route_points": [
|
||||
{
|
||||
"model_x": s["center_x"],
|
||||
"model_y": 200.0,
|
||||
"chainage_m": s["chainage_m"],
|
||||
"sequence_num": i,
|
||||
}
|
||||
for i, s in enumerate(STATIONS)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_root(project_id):
|
||||
return tmp_path
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
|
||||
|
||||
가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 ·
|
||||
복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 ·
|
||||
덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
|
||||
가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 ·
|
||||
3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 ·
|
||||
갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from common_util.common_util_derived_store import derived_dir, write_derived
|
||||
from common_util.common_util_print_pdf import PrintError
|
||||
from common_util.common_util_spreadsheet import workbook_pdf
|
||||
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
|
||||
@@ -24,7 +25,11 @@ from E05_StabReport import E05_StabReport_Store as store
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
N = 30 # 측점 수
|
||||
NG_AT = {3, 10, 17, 24} # N.G 측점
|
||||
REPS = [("grid:3", "lowest_fs"), ("grid:12", "cut_15m"), ("grid:20", "fill_10m")]
|
||||
REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")]
|
||||
# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔
|
||||
OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False}
|
||||
KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station"
|
||||
REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
|
||||
|
||||
|
||||
def _key(i: int) -> str:
|
||||
@@ -32,17 +37,49 @@ def _key(i: int) -> str:
|
||||
|
||||
|
||||
def _summary(i: int, bump: float = 0.0) -> dict:
|
||||
ng = i in NG_AT
|
||||
"""E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율."""
|
||||
ng = i in NG_AT and not bump
|
||||
dry = 1.31 if ng else 1.7 + i / 100 + bump
|
||||
return {
|
||||
"station_key": _key(i),
|
||||
"chainage_m": 20.0 * i,
|
||||
"side": "cut" if i % 2 else "fill",
|
||||
"fs_min": dry - 0.2,
|
||||
"fs_by_condition": {"dry": dry, "wet": dry - 0.2},
|
||||
"method": "bishop",
|
||||
"verdict": "ng" if ng else "ok",
|
||||
role = "cut" if i % 2 else "fill"
|
||||
side = "left" if role == "cut" else "right"
|
||||
other = "right" if side == "left" else "left"
|
||||
heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0}
|
||||
fs = {"dry": dry, "wet": dry - 0.2}
|
||||
cases = {
|
||||
cond: {
|
||||
side: {"fs": fs[cond], "required": REQUIRED[cond][role]},
|
||||
other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]},
|
||||
}
|
||||
for cond in fs
|
||||
}
|
||||
return {
|
||||
"key": _key(i),
|
||||
"chainage_m": 20.0 * i,
|
||||
"label": f"No.{i}",
|
||||
"fp": f"st{i}",
|
||||
"sides": [
|
||||
{"side": "left", "role": "cut", "height_m": heights["cut"]},
|
||||
{"side": "right", "role": "fill", "height_m": heights["fill"]},
|
||||
],
|
||||
"cases": cases,
|
||||
"min": {c: min(v["fs"] for v in cases[c].values()) for c in cases},
|
||||
"verdict": {
|
||||
c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng"
|
||||
for c in cases
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _write_summary(root: Path, fp: str, rows: list[dict]) -> None:
|
||||
value = {
|
||||
"fingerprint": fp,
|
||||
"computed_at": "2026-10-25T10:00:00",
|
||||
"conditions": ["dry", "wet"],
|
||||
"method": "bishop",
|
||||
"stations": rows,
|
||||
}
|
||||
write_derived(root, KIND_SUMMARY, fp, value)
|
||||
|
||||
|
||||
def _slip(F: float) -> dict:
|
||||
@@ -54,7 +91,7 @@ def _station_doc(i: int) -> dict:
|
||||
s = _summary(i)
|
||||
cases = []
|
||||
for cond in ("dry", "wet"):
|
||||
F = s["fs_by_condition"][cond]
|
||||
F = s["min"][cond]
|
||||
for side, role in (("left", "cut"), ("right", "fill")):
|
||||
cases.append(
|
||||
{
|
||||
@@ -94,32 +131,13 @@ def _cross(chainage: float) -> dict:
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch) -> Path:
|
||||
root = tmp_path / "e_project"
|
||||
d2 = root / "E03_StabSection" / "_derived"
|
||||
(d2 / "results").mkdir(parents=True)
|
||||
(d2 / "result2d.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_fingerprint": "fp-1",
|
||||
"computed_at": "2026-10-25T10:00:00",
|
||||
"stations": [_summary(i) for i in range(N)],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_summary(root, "fp-1", [_summary(i) for i in range(N)])
|
||||
for key, _ in REPS:
|
||||
i = int(key.split(":")[1])
|
||||
(d2 / "results" / f"grid_{i}.json").write_text(
|
||||
json.dumps(_station_doc(i)), encoding="utf-8"
|
||||
)
|
||||
write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N)
|
||||
(root / "E03_StabSection").mkdir()
|
||||
(root / "E03_StabSection" / "representative.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"rule": {"fill_h_m": 10, "cut_h_m": 15, "lowest_n": 5},
|
||||
"stations": [{"station_key": k, "reason": r, "enabled": True} for k, r in REPS]
|
||||
+ [{"station_key": _key(5), "reason": "user", "enabled": False}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
json.dumps({"overrides": OFF}), encoding="utf-8"
|
||||
)
|
||||
ground = ground_model.pick(ground_model.load_defaults())
|
||||
ground["stations"] = [
|
||||
@@ -139,7 +157,15 @@ def project(tmp_path, monkeypatch) -> Path:
|
||||
{
|
||||
"fingerprint": "3d-1",
|
||||
"settings": {},
|
||||
"summary": {"start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0},
|
||||
"summary": {
|
||||
"start_m": 0.0,
|
||||
"end_m": 200.0,
|
||||
"f_min": 1.42,
|
||||
"volume": 830.0,
|
||||
"condition": "wet",
|
||||
"reference_fs": 1.3,
|
||||
"verdict": "ok",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
@@ -197,12 +223,85 @@ def test_처음_만듦_측점수_NG_그림(project):
|
||||
assert s3["A26"]["값"] == "풍화토" and s3["B32"]["값"].startswith("0 ~ 100 m 위 지층")
|
||||
assert s3["A32"]["값"] == "붕적토" and s3["B13"]["값"] == 1.3
|
||||
s6 = by["3D요약"]["칸"]
|
||||
assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "N.G", "서식": build.ST_NG}
|
||||
assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 최소
|
||||
assert s6["D20"]["값"] == 1.42 and s6["H20"] == {"값": "O.K", "서식": build.ST_OK} # E04 판정
|
||||
assert s6["E20"]["값"] == pytest.approx(1.11) # 0~200 m 안 2D 우기 최소(3D 와 같은 조건)
|
||||
assert (build.initial_dir(project) / build.REPORT).is_file()
|
||||
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)
|
||||
@@ -216,11 +315,9 @@ def test_갱신_사용자몫_불변_덮임(project):
|
||||
by = _sheets(book)
|
||||
by["측점표"]["칸"]["A6"]["값"] = "고친 측점"
|
||||
store.save(project, book)
|
||||
r2 = project / build.RESULT2D
|
||||
doc = json.loads(r2.read_text(encoding="utf-8"))
|
||||
doc["stations"][3] = _summary(4, 0.5) | {"station_key": _key(3), "chainage_m": 60.0}
|
||||
doc["input_fingerprint"] = "fp-2"
|
||||
r2.write_text(json.dumps(doc), encoding="utf-8")
|
||||
rows = [_summary(i) for i in range(N)]
|
||||
rows[3] = _summary(3, 0.5)
|
||||
_write_summary(project, "fp-2", rows)
|
||||
assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"]
|
||||
try:
|
||||
out = store.refresh(project)
|
||||
@@ -251,7 +348,7 @@ def test_3D_없음_2D_없음(project):
|
||||
by = _sheets(book)
|
||||
assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전"
|
||||
assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전"
|
||||
(project / build.RESULT2D).unlink()
|
||||
shutil.rmtree(derived_dir(project, KIND_SUMMARY))
|
||||
with pytest.raises(build.MissingInput):
|
||||
build.gather(project)
|
||||
|
||||
@@ -262,7 +359,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(
|
||||
@@ -278,8 +375,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():
|
||||
@@ -317,7 +414,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,426 @@
|
||||
"""PLAN 536-1 — E06 암반 평사투영 · Markland · Hoek-Bray 평면 · 쐐기 (요구 345 · 351).
|
||||
|
||||
기준 예제(문헌 값 · Rocscience 검증서가 옮긴 값):
|
||||
- 평면 = Hoek(Sau Mau Ping, 『Practical Rock Engineering』 7장 · RocPlane 검증 #1) 균열 없음 0.8184254 ·
|
||||
임계 균열 + 수위 0.9z 1.0654738 / Sharma 외(1995) 윗면 0~30° 무게(RocPlane 검증 #2)
|
||||
- 쐐기 = Hoek & Bray 쐐기 계수(대칭 쐐기 마찰만 · 해석해) · Kovári & Fritz · Kumsar 외(2000) 실험 ·
|
||||
Priest(1993) 두 예제(SWedge 검증 #1~#7 값)
|
||||
- Markland 경계 · 투영 왕복 · 위험 영역 = 판정 부등식
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from E06_StabRock import E06_StabRock_Engine as eng
|
||||
from E06_StabRock import E06_StabRock_Model as model
|
||||
from E06_StabRock import E06_StabRock_Stereonet as net
|
||||
|
||||
TOL = 0.02 # 완료 기준 ±2 %
|
||||
|
||||
|
||||
def close(value, expect, tol=TOL):
|
||||
assert value == pytest.approx(expect, rel=tol), (value, expect)
|
||||
|
||||
|
||||
# ---- 평면 (Hoek-Bray) -------------------------------------------------------------
|
||||
SAU_MAU = dict(
|
||||
H=60, slope_dip=50, plane_dip=35, c=100, phi=35, gamma=27, kh=0.08, gamma_w=10
|
||||
) # MN → kN · 0.1 MPa = 100 kPa
|
||||
|
||||
|
||||
def test_plane_hoek_no_crack():
|
||||
r = eng.plane_fs(**SAU_MAU, water_fill=1.0)
|
||||
close(r["F"], 0.8184254, 1e-4)
|
||||
close(r["W"], 28627.8, 1e-4)
|
||||
close(r["A"], 104.6068, 1e-5)
|
||||
close(r["U"], 15691.0, 1e-4)
|
||||
|
||||
|
||||
def test_plane_hoek_tension_crack():
|
||||
z = eng.plane_critical_depth_hoek(60, 50, 35)
|
||||
close(z, 14.0092, 1e-5)
|
||||
r = eng.plane_fs(**SAU_MAU, water_fill=0.9, crack={"mode": "depth", "z": z})
|
||||
close(r["F"], 1.0654738, 1e-4)
|
||||
close(r["W"], 24843.9, 1e-4)
|
||||
close(r["A"], 80.1826, 1e-5)
|
||||
close(r["crack_b"], 15.3357, 1e-4) # 마루 뒤 거리(RocPlane 검증 #1)
|
||||
|
||||
|
||||
def test_plane_critical_crack_search_matches_hoek():
|
||||
"""건조 · 윗면 수평 — 수치 탐색 임계 자리 = Hoek-Bray 식 z · b."""
|
||||
r = eng.plane_fs(
|
||||
H=60, slope_dip=50, plane_dip=35, c=20, phi=35, gamma=27, crack={"mode": "critical"}
|
||||
)
|
||||
close(r["z"], eng.plane_critical_depth_hoek(60, 50, 35), 1e-4)
|
||||
close(r["crack_b"], 15.3357, 1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"upper,weight,fs,fill",
|
||||
[
|
||||
(0, 2392.38, 1.58612, 1.00),
|
||||
(10, 2446.29, 1.58148, 0.84),
|
||||
(20, 2503.66, 1.58382, 0.71),
|
||||
(30, 2568.90, 1.57849, 0.61),
|
||||
],
|
||||
)
|
||||
def test_plane_upper_slope_sharma(upper, weight, fs, fill):
|
||||
"""윗면 기운 사면 · 연직 균열 마루 뒤 15.336 m · 균열 수위(RocPlane 정수 %)."""
|
||||
kw = dict(
|
||||
H=60, slope_dip=50, plane_dip=35, c=12, phi=45, gamma=2.6, gamma_w=1.0, upper_dip=upper
|
||||
)
|
||||
r = eng.plane_fs(**kw, crack={"mode": "x", "b": 15.33576}, water_fill=fill)
|
||||
close(r["W"], weight, 1e-4)
|
||||
close(r["F"], fs)
|
||||
|
||||
|
||||
def test_plane_bolt_vector():
|
||||
"""수동 볼트 — 저항 += T cos(θ+ψp) + T sin(θ+ψp) tanφ (힘 벡터 풀이)."""
|
||||
base = dict(H=10, slope_dip=60, plane_dip=35, c=10, phi=30, gamma=25)
|
||||
r0 = eng.plane_fs(**base)
|
||||
r1 = eng.plane_fs(**base, bolt_t=100, bolt_plunge=15)
|
||||
a = math.radians(50)
|
||||
expect = (
|
||||
r0["resist"] + 100 * math.cos(a) + 100 * math.sin(a) * math.tan(math.radians(30))
|
||||
) / r0["drive"]
|
||||
close(r1["F"], expect, 1e-9)
|
||||
|
||||
|
||||
def test_plane_not_formed():
|
||||
assert eng.plane_fs(H=10, slope_dip=40, plane_dip=45, c=0, phi=30, gamma=25) is None
|
||||
# 윗면이 활동면보다 가파르면 덩이 안 생김(균열 없음)
|
||||
assert (
|
||||
eng.plane_fs(H=10, slope_dip=60, plane_dip=30, c=0, phi=20, gamma=25, upper_dip=35) is None
|
||||
)
|
||||
|
||||
|
||||
# ---- 쐐기 (Hoek-Bray · SWedge 꼴) ------------------------------------------------
|
||||
FLAT = {"dip_direction": 180, "dip": 0}
|
||||
|
||||
|
||||
def _wedge(j1, j2, slope, phi, H=10, upper=FLAT, **kw):
|
||||
return eng.wedge_fs(
|
||||
j1={"dip_direction": j1[0], "dip": j1[1], "c": 0, "phi": phi},
|
||||
j2={"dip_direction": j2[0], "dip": j2[1], "c": 0, "phi": phi},
|
||||
slope={"dip_direction": slope[0], "dip": slope[1]},
|
||||
upper=upper,
|
||||
H=H,
|
||||
gamma=26,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"j1,j2", [((141, 45), (219, 45)), ((119, 50), (241, 50)), ((150, 60), (230, 55))]
|
||||
)
|
||||
def test_wedge_hoek_bray_wedge_factor(j1, j2):
|
||||
"""마찰만 · 같은 φ — Hoek & Bray FS = K tanφ / tanψi · K = sinβ / sin(ξ/2)
|
||||
(ξ = 두 면 사이 각(교선에 수직 단면) · β = 그 단면에서 쐐기 이등분선의 기울기)."""
|
||||
phi = 35
|
||||
r = _wedge(j1, j2, (180, 70), phi)
|
||||
n1 = eng.plane_normal(*j1)
|
||||
n2 = eng.plane_normal(*j2)
|
||||
i = eng.line_vector(*r["line"])
|
||||
# 교선에 수직인 면 안에서 두 면 안쪽 법선(위쪽) 사이 각 → 쐐기 사이 각 ξ = 180 − 그 각
|
||||
xi = math.pi - math.acos(float(n1 @ n2))
|
||||
bis = (n1 + n2) / np.linalg.norm(n1 + n2) # 쐐기 이등분선(교선에 수직 단면 안)
|
||||
h = np.cross(i, [0.0, 0.0, 1.0])
|
||||
h /= np.linalg.norm(h) # 그 단면의 수평
|
||||
beta = math.acos(abs(float(bis @ h)))
|
||||
K = math.sin(beta) / math.sin(xi / 2)
|
||||
expect = K * math.tan(math.radians(phi)) / math.tan(math.radians(r["line"][1]))
|
||||
close(r["F"], expect, 1e-3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"j1,j2,slope,phi,plunge,fs",
|
||||
[
|
||||
((141, 45), (219, 45), (180, 70), 35, 37.85, 1.0), # Kovári & Fritz · SWedge #1
|
||||
((119, 50), (241, 50), (180, 70), 35, 30.02, 1.6325), # SWedge #2
|
||||
((195, 45), (105, 70), (160, 70), 30, None, 0.73), # Kumsar Case 3(Ankara) · SWedge 0.7123
|
||||
((33.5, 75), (248, 75), (337.5, 75), 40.8, None, 2.02), # Kumsar Case 4(Dinar)
|
||||
],
|
||||
)
|
||||
def test_wedge_kumsar_kovari(j1, j2, slope, phi, plunge, fs):
|
||||
r = _wedge(j1, j2, slope, phi)
|
||||
assert r["mode"] == "12"
|
||||
close(r["F"], fs, 0.03 if fs == 0.73 else 0.01) # 실험값 0.73 = 판독(SWedge 0.7123)
|
||||
if plunge:
|
||||
close(r["line"][1], plunge, 1e-3)
|
||||
|
||||
|
||||
def test_wedge_kumsar_seismic():
|
||||
r = _wedge((33.5, 75), (248, 75), (337.5, 75), 40.8, kh=0.3303)
|
||||
close(r["F"], 0.99) # Kumsar 0.99 · SWedge 0.9872
|
||||
|
||||
|
||||
def test_wedge_priest_double_plane():
|
||||
"""Priest(1993) 예제 1 — 두 면 미끄럼 · 수압 5 · 15 kPa · 철탑 180 kN(168/70)."""
|
||||
r = eng.wedge_fs(
|
||||
j1={"dip_direction": 203, "dip": 47, "c": 10, "phi": 40},
|
||||
j2={"dip_direction": 287, "dip": 52, "c": 20, "phi": 35},
|
||||
slope={"dip_direction": 230, "dip": 60},
|
||||
upper={"dip_direction": 225, "dip": 5},
|
||||
H=6.7978,
|
||||
gamma=26,
|
||||
water={"pressure": [5, 15]},
|
||||
external=[{"force": 180, "trend": 168, "plunge": 70}],
|
||||
)
|
||||
assert r["mode"] == "12"
|
||||
close(r["F"], 1.4966, 1e-3) # Priest ≈ 1.5
|
||||
close(r["volume"], 45.201, 1e-3)
|
||||
close(r["areas"][0], 41.147, 1e-3)
|
||||
close(r["areas"][1], 20.428, 1e-3)
|
||||
close(r["face_area"], 38.955, 1e-3)
|
||||
close(r["N"][0], 407, 3e-3)
|
||||
close(r["N"][1], 251, 3e-3)
|
||||
|
||||
|
||||
def test_wedge_priest_single_plane():
|
||||
"""Priest(1993) 예제 2 — 면 2 하나로 미끄럼(면 1 떨어짐) · 수압 25 · 15 kPa."""
|
||||
r = eng.wedge_fs(
|
||||
j1={"dip_direction": 65, "dip": 74, "c": 15, "phi": 32},
|
||||
j2={"dip_direction": 186, "dip": 41, "c": 5, "phi": 40},
|
||||
slope={"dip_direction": 134, "dip": 65},
|
||||
upper={"dip_direction": 122, "dip": 11},
|
||||
H=6.8471,
|
||||
gamma=25,
|
||||
water={"pressure": [25, 15]},
|
||||
)
|
||||
assert r["mode"] == "2"
|
||||
close(r["F"], 0.8493, 1e-3) # Priest ≈ 0.86 · SWedge 0.8493
|
||||
close(r["volume"], 81.741, 1e-3)
|
||||
assert r["N"][0] == 0.0
|
||||
|
||||
|
||||
def test_wedge_gravity_water_and_bolt():
|
||||
"""중력 수압 U = p³ γw Hw a / 6(SWedge · Hoek-Bray) · 볼트는 안전율을 낮추지 않음."""
|
||||
dry = _wedge((141, 45), (219, 45), (180, 70), 35)
|
||||
wet = _wedge((141, 45), (219, 45), (180, 70), 35, water={"fill": 1.0})
|
||||
expect = eng.GAMMA_W * wet["Hw"] / 6 * wet["areas"][0]
|
||||
close(wet["water_force"][0], expect, 1e-9)
|
||||
assert wet["F"] < dry["F"]
|
||||
bolted = _wedge(
|
||||
(141, 45), (219, 45), (180, 70), 35, bolt={"force": 50, "trend": 0, "plunge": 15}
|
||||
)
|
||||
assert bolted["F"] > dry["F"]
|
||||
|
||||
|
||||
def test_wedge_not_formed():
|
||||
# 교선이 사면보다 가파름 → 사면으로 안 나옴
|
||||
assert _wedge((141, 75), (219, 75), (180, 50), 35) is None
|
||||
|
||||
|
||||
# ---- Markland 경계 -------------------------------------------------------------
|
||||
SLOPE = {"dip_direction": 90.0, "dip": 70.0}
|
||||
|
||||
|
||||
def _j(dd, dip, phi=30.0, jid="J"):
|
||||
return {"id": jid, "dip_direction": dd, "dip": dip, "phi": phi}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dd,dip,hit",
|
||||
[
|
||||
(109.9, 45, True),
|
||||
(110.1, 45, False),
|
||||
(70.1, 45, True),
|
||||
(90, 30.1, True),
|
||||
(90, 29.9, False),
|
||||
(90, 69.9, True),
|
||||
(90, 70.1, False),
|
||||
],
|
||||
)
|
||||
def test_markland_planar(dd, dip, hit):
|
||||
assert (eng.kinematic(SLOPE, [_j(dd, dip)])["planar"] == ["J"]) is hit
|
||||
|
||||
|
||||
def test_markland_planar_apparent_daylight():
|
||||
"""측방 15° — 겉보기 사면 경사(tanψf cos15°)를 넘는 절리는 안 나옴."""
|
||||
app = eng.apparent_dip(70, 90, 105)
|
||||
assert eng.kinematic(SLOPE, [_j(105, app - 0.1)])["planar"] == ["J"]
|
||||
assert eng.kinematic(SLOPE, [_j(105, app + 0.1)])["planar"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dd,dip,hit", [(270, 50.1, True), (270, 49.9, False), (289.9, 80, True), (290.1, 80, False)]
|
||||
)
|
||||
def test_markland_toppling(dd, dip, hit):
|
||||
"""ψp > 90 − ψf + φ = 50 · 역방향 ±20."""
|
||||
assert (eng.kinematic(SLOPE, [_j(dd, dip)])["toppling"] == ["J"]) is hit
|
||||
|
||||
|
||||
def test_markland_wedge_plunge_bounds():
|
||||
a, b = _j(50, 60, jid="A"), _j(130, 60, jid="B")
|
||||
trend, plunge = eng.intersection(a, b)
|
||||
assert trend == pytest.approx(90.0, abs=1e-6)
|
||||
hit = eng.kinematic(SLOPE, [a, b])["wedge"]
|
||||
assert hit and hit[0]["ids"] == ["A", "B"]
|
||||
# φ 를 선주각 위로 올리면 사라짐
|
||||
hi = [dict(a, phi=plunge + 0.1), dict(b, phi=plunge + 0.1)]
|
||||
assert eng.kinematic(SLOPE, hi)["wedge"] == []
|
||||
# 사면을 교선보다 완만하게 하면 안 나옴
|
||||
assert eng.kinematic({"dip_direction": 90, "dip": plunge - 0.1}, [a, b])["wedge"] == []
|
||||
|
||||
|
||||
# ---- 투영 ----------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("kind", net.PROJECTIONS)
|
||||
def test_projection_round_trip(kind):
|
||||
rng = np.random.default_rng(536)
|
||||
for trend, plunge in zip(rng.uniform(0, 360, 200), rng.uniform(0, 89.9, 200)):
|
||||
x, y = net.project(trend, plunge, kind)
|
||||
t2, p2 = net.inverse(x, y, kind)
|
||||
assert p2 == pytest.approx(plunge, abs=2e-3)
|
||||
# 좌표는 소수 다섯째 자리 — 중심 가까이는 추향 오차가 1e-5 / r rad 까지
|
||||
assert eng.ang_diff(t2, trend) <= math.degrees(1e-5 / math.hypot(x, y)) + 1e-6
|
||||
|
||||
|
||||
def test_projection_known_radii():
|
||||
assert net.radius(0, "equal_angle") == pytest.approx(1.0)
|
||||
assert net.radius(0, "equal_area") == pytest.approx(1.0)
|
||||
assert net.radius(90, "equal_angle") == pytest.approx(0.0)
|
||||
assert net.radius(45, "equal_angle") == pytest.approx(math.tan(math.radians(22.5)))
|
||||
assert net.radius(45, "equal_area") == pytest.approx(
|
||||
math.sqrt(2) * math.sin(math.radians(22.5))
|
||||
)
|
||||
# 동쪽 수평선 = (1, 0) · 북쪽 = (0, 1)
|
||||
assert net.project(90, 0, "equal_angle") == pytest.approx([1.0, 0.0], abs=1e-5)
|
||||
assert net.project(0, 0, "equal_area") == pytest.approx([0.0, 1.0], abs=1e-5)
|
||||
|
||||
|
||||
def _inside(pt, poly):
|
||||
x, y = pt
|
||||
hit = False
|
||||
for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
|
||||
if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
|
||||
hit = not hit
|
||||
return hit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", net.PROJECTIONS)
|
||||
def test_zones_match_kinematic(kind):
|
||||
"""위험 영역 다각형 안 극점 · 교선 = Markland 판정(경계 1° 띠 밖 표본)."""
|
||||
rng = np.random.default_rng(7)
|
||||
phi = 30.0
|
||||
data = net.stereonet(SLOPE, [], phi, kind)
|
||||
checked = 0
|
||||
for dd, dip in zip(rng.uniform(0, 360, 1500), rng.uniform(1, 89, 1500)):
|
||||
kin = eng.kinematic(SLOPE, [_j(dd, dip, phi)])
|
||||
near = (
|
||||
abs(eng.ang_diff(dd, 90) - 20) < 1
|
||||
or abs(eng.ang_diff(dd, 270) - 20) < 1
|
||||
or abs(dip - phi) < 1
|
||||
or abs(dip - eng.apparent_dip(70, 90, dd)) < 1
|
||||
or abs(dip - 50) < 1
|
||||
)
|
||||
if near:
|
||||
continue
|
||||
pole = net.project(*eng.pole(dd, dip), kind)
|
||||
assert _inside(pole, data["zones"]["planar"]) == bool(kin["planar"]), (dd, dip)
|
||||
assert _inside(pole, data["zones"]["toppling"]) == bool(kin["toppling"]), (dd, dip)
|
||||
checked += 1
|
||||
assert checked > 1000
|
||||
|
||||
|
||||
# ---- 정본 · 측점 사면 ------------------------------------------------------------
|
||||
def _doc():
|
||||
doc = model.build_initial()
|
||||
doc["sets"] = [
|
||||
{
|
||||
"id": "J1",
|
||||
"name": "J1",
|
||||
"from_m": 0,
|
||||
"to_m": 100,
|
||||
"dip_direction": 90,
|
||||
"dip": 45,
|
||||
"phi": 30,
|
||||
"c": 5,
|
||||
"persistence_m": None,
|
||||
}
|
||||
]
|
||||
return doc
|
||||
|
||||
|
||||
def test_check_and_clean():
|
||||
assert model.check_joints(_doc()) == []
|
||||
bad = _doc()
|
||||
bad["sets"][0].update(dip=95, to_m=-1, name=" ")
|
||||
bad["sets"].append(dict(bad["sets"][0]))
|
||||
bad["settings"]["crack"] = {"mode": "depth", "z": None}
|
||||
paths = {e["path"] for e in model.check_joints(bad)}
|
||||
assert {"sets.0.dip", "sets.0.to_m", "sets.0.name", "sets.1.id", "settings.crack.z"} <= paths
|
||||
clean = model.clean(dict(_doc(), extra=1))
|
||||
assert set(clean) == {"version", "sets", "settings"}
|
||||
assert clean["sets"][0]["dip"] == 45.0
|
||||
|
||||
|
||||
def _cross(side_cut: str):
|
||||
"""좌(+offset) 또는 우 깎기 — 노선 북쪽 진행(left = 서쪽 −x) · 원지반 깎기 쪽으로 0.6 오름 ·
|
||||
노면 99 · 깎기 1:0.4 · 쌓기 1:1.5."""
|
||||
sign = 1.0 if side_cut == "left" else -1.0
|
||||
offs = np.arange(-20, 20.5, 0.5)
|
||||
ground = [[float(o), 100 + 0.6 * sign * o] for o in offs]
|
||||
design = []
|
||||
for o, z in ground:
|
||||
d = sign * o
|
||||
if abs(o) <= 2:
|
||||
z = 99.0
|
||||
elif d > 2:
|
||||
z = min(z, 99 + 2.5 * (d - 2))
|
||||
else:
|
||||
z = max(z, 99 - (abs(d) - 2) / 1.5)
|
||||
design.append([o, z])
|
||||
return (
|
||||
{
|
||||
"frame": {"left_xy": [-1.0, 0.0]},
|
||||
"samples": [{"offset_m": o, "elevation_m": z} for o, z in ground],
|
||||
},
|
||||
{
|
||||
"section_mode": f"{side_cut}_cut",
|
||||
"cut_slope_ratio": 0.4,
|
||||
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
|
||||
"design_line": [{"offset_m": o, "elevation_m": z} for o, z in design],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side,dd", [("left", 90.0), ("right", 270.0)])
|
||||
def test_station_slope_orientation(side, dd):
|
||||
"""좌 = 서쪽 산 · 사면이 동(노면)으로 기욺 = 경사방향 90 · 우는 270 · 경사 = atan(1/0.4)."""
|
||||
cross, design = _cross(side)
|
||||
slopes = model.station_slopes(cross, design)
|
||||
assert len(slopes) == 1 and slopes[0]["side"] == side
|
||||
assert slopes[0]["dip_direction"] == pytest.approx(dd)
|
||||
assert slopes[0]["dip"] == pytest.approx(math.degrees(math.atan(2.5)), abs=0.01)
|
||||
assert slopes[0]["height_m"] > 1
|
||||
assert slopes[0]["upper_dip"] == pytest.approx(math.degrees(math.atan(0.6)), abs=0.5)
|
||||
|
||||
|
||||
def test_analyze_station(monkeypatch):
|
||||
cross, design = _cross("left")
|
||||
monkeypatch.setattr(
|
||||
model,
|
||||
"rock_stations",
|
||||
lambda _s: [{"chainage_m": 20.0, "label": "STA", "ground_type": "ripping_rock"}],
|
||||
)
|
||||
monkeypatch.setattr(model.src, "cross_section", lambda _s, _c: {**cross, "design": design})
|
||||
ground = {
|
||||
"materials": [],
|
||||
"stations": [],
|
||||
"safety_factors": {"dry": {"cut": 1.5}, "wet": {"cut": 1.2}},
|
||||
"seismic": {"enabled": False},
|
||||
}
|
||||
out = model.analyze(None, _doc(), ground)
|
||||
assert len(out) == 1
|
||||
e = out[0]
|
||||
assert e["kinematic"]["planar"] == ["J1"]
|
||||
assert {c["condition"] for c in e["cases"]} == {"dry", "wet"}
|
||||
dry = next(c for c in e["cases"] if c["condition"] == "dry")
|
||||
assert dry["required"] == 1.5 and dry["ok"] == (dry["F"] >= 1.5)
|
||||
assert e["verdict"] in ("OK", "NG")
|
||||
assert e["gamma_r"] == model.DEFAULT_GAMMA_R
|
||||
nets = model.station_net(e, _doc()["settings"])
|
||||
assert set(nets) == {"equal_angle", "equal_area", "phi"}
|
||||
assert nets["equal_area"]["zones"]["planar"]
|
||||
@@ -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 수정은 불필요하다.
|
||||
* ========================================================================== */
|
||||
@@ -35,6 +36,8 @@ import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
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;
|
||||
@@ -77,6 +80,8 @@ export const ui_locales = {
|
||||
...ui_locales_de,
|
||||
...ui_locales_e,
|
||||
...ui_locales_e05,
|
||||
...ui_locales_e06,
|
||||
...ui_locales_e07,
|
||||
} as const;
|
||||
|
||||
export type LocaleKey = keyof typeof ui_locales;
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ui_locales_de = {
|
||||
DE_Step_StabSection: ["2D 해석", "2D Analysis"],
|
||||
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,122 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_e06.ts
|
||||
* 다국어 사전 — E06 암반 평사투영 해석(요구 345 · 351 · PLAN 536-1)
|
||||
*
|
||||
* 신규 문구는 해당 섹션 최하단에 추가.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
|
||||
export const ui_locales_e06 = {
|
||||
/* --- E06 좌측 --- */
|
||||
E06_Note: [
|
||||
"암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.",
|
||||
"Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).",
|
||||
],
|
||||
E06_Card_Joints: ["절리군", "Joint sets"],
|
||||
E06_JointsHint: [
|
||||
"현장 조사값 — 경사방향 · 경사(°) · 절리면 φj(°) · cj(kPa) · 지속성(m · 비우면 무한). 구간(m) 안 측점에만 씁니다.",
|
||||
"Field data — dip direction · dip (°) · joint φj (°) · cj (kPa) · persistence (m, blank = unlimited). Applies to stations within the range (m).",
|
||||
],
|
||||
E06_Col_Name: ["이름", "Name"],
|
||||
E06_Col_From: ["시작", "From"],
|
||||
E06_Col_To: ["끝", "To"],
|
||||
E06_Col_DipDir: ["경사방향", "Dip dir."],
|
||||
E06_Col_Dip: ["경사", "Dip"],
|
||||
E06_Col_Phi: ["φj", "φj"],
|
||||
E06_Col_C: ["cj", "cj"],
|
||||
E06_Col_Persist: ["지속성", "Persist."],
|
||||
E06_Add: ["+ 절리군", "+ Joint set"],
|
||||
E06_Remove: ["지움", "Remove"],
|
||||
E06_NoSets: [
|
||||
"절리군이 없습니다 — [+ 절리군] 으로 넣으세요.",
|
||||
"No joint sets — add one with [+ Joint set].",
|
||||
],
|
||||
E06_Card_Settings: ["해석 설정", "Analysis settings"],
|
||||
E06_Set_LateralPlanar: ["평면 측방 한계 (°)", "Planar lateral limit (°)"],
|
||||
E06_Set_LateralTopple: ["전도 측방 한계 (°)", "Toppling lateral limit (°)"],
|
||||
E06_Set_Gamma: ["암 단위중량 (kN/m³ · 비우면 E02)", "Rock unit weight (kN/m³, blank = E02)"],
|
||||
E06_Set_WaterWet: ["우기 수위 비 (0~1)", "Wet water ratio (0–1)"],
|
||||
E06_Set_Crack: ["인장균열", "Tension crack"],
|
||||
E06_Crack_None: ["없음", "None"],
|
||||
E06_Crack_Critical: ["임계(최소 안전율 자리)", "Critical (minimum FS)"],
|
||||
E06_Crack_Depth: ["깊이 지정", "Given depth"],
|
||||
E06_Set_CrackZ: ["균열 깊이 (m)", "Crack depth (m)"],
|
||||
E06_Set_BoltForce: ["볼트 힘 (평면 kN/m · 쐐기 kN)", "Bolt force (plane kN/m · wedge kN)"],
|
||||
E06_Set_BoltPlunge: ["볼트 경사 (수평 아래 °)", "Bolt plunge (below horizontal °)"],
|
||||
E06_SettingsHint: [
|
||||
"측방 한계 기본 20° (Dips 기본 · 지식DB 03 전도는 30°) · 우기 = 비탈 높이 ½ 수위(KDS 11 70 05) · 볼트 = 수동 지지력.",
|
||||
"Lateral limit default 20° (Dips default; knowledge DB uses 30° for toppling) · wet = water at half height (KDS 11 70 05) · bolts = passive support.",
|
||||
],
|
||||
E06_Analyze: ["해석", "Analyze"],
|
||||
E06_Running: ["해석 중…", "Analyzing…"],
|
||||
E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."],
|
||||
E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."],
|
||||
E06_Saved: ["저장했습니다.", "Saved."],
|
||||
E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
|
||||
E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
|
||||
E06_Invalid: ["틀린 칸", "Invalid fields"],
|
||||
E06_Err_Range: ["범위 밖", "Out of range"],
|
||||
E06_ResetConfirm: [
|
||||
"절리군 · 설정을 초기값(절리 없음)으로 되돌릴까요?",
|
||||
"Reset joint sets and settings to the initial state (no joints)?",
|
||||
],
|
||||
E06_ResetDone: ["초기값으로 되돌렸습니다.", "Reset to initial values."],
|
||||
E06_NoProject: ["열린 프로젝트가 없습니다.", "No project is open."],
|
||||
|
||||
/* --- E06 가운데 · 우측 --- */
|
||||
E06_Stale: [
|
||||
"절리 · 지반 조건이 해석 뒤 바뀌었습니다 — [해석] 을 다시 누르세요.",
|
||||
"Joints or ground changed since the analysis — press [Analyze] again.",
|
||||
],
|
||||
E06_Band: ["위험 띠", "Risk band"],
|
||||
E06_View_All: ["전체", "All"],
|
||||
E06_Mode_planar: ["평면", "Planar"],
|
||||
E06_Mode_wedge: ["쐐기", "Wedge"],
|
||||
E06_Mode_toppling: ["전도", "Toppling"],
|
||||
E06_Proj_Angle: ["등각", "Equal angle"],
|
||||
E06_Proj_Area: ["등면적", "Equal area"],
|
||||
E06_PickStation: ["측점을 고르세요.", "Pick a station."],
|
||||
E06_Lg_Slope: ["사면 대원 · 극점", "Slope plane · pole"],
|
||||
E06_Lg_Friction: ["마찰원 φ", "Friction circle φ"],
|
||||
E06_Lg_Planar: ["평면 위험(극점)", "Planar zone (poles)"],
|
||||
E06_Lg_Daylight: ["일광 엔벨로프", "Daylight envelope"],
|
||||
E06_Lg_Wedge: ["쐐기 위험(교선)", "Wedge zone (intersections)"],
|
||||
E06_Lg_Line: ["교선", "Intersection"],
|
||||
E06_Lg_Toppling: ["전도 위험(극점)", "Toppling zone (poles)"],
|
||||
E06_Lg_Pole: ["절리 극점", "Joint pole"],
|
||||
E06_Result_Title: ["측점 결과", "Station results"],
|
||||
E06_NoResult: ["해석 결과가 없습니다 — [해석] 을 누르세요.", "No results — press [Analyze]."],
|
||||
E06_NoRock: ["암 측점 깎기 비탈이 없습니다.", "No rock cut slopes."],
|
||||
E06_L_Station: ["측점", "Station"],
|
||||
E06_L_Side: ["쪽", "Side"],
|
||||
E06_L_Modes: ["평 · 쐐 · 전", "P · W · T"],
|
||||
E06_L_MinF: ["최소 Fs", "Min Fs"],
|
||||
E06_L_Verdict: ["판정", "Verdict"],
|
||||
E06_Left: ["좌", "L"],
|
||||
E06_Right: ["우", "R"],
|
||||
E06_V_NG: ["N.G", "N.G"],
|
||||
E06_V_check: ["검토", "Check"],
|
||||
E06_V_OK: ["O.K", "O.K"],
|
||||
E06_V_none: ["위험 없음", "No risk"],
|
||||
E06_SlopeLine: [
|
||||
"사면 {dd}° / {dip}° · 높이 {h} m · 윗면 {up}° · 암 γ {g} kN/m³",
|
||||
"Slope {dd}° / {dip}° · height {h} m · upper {up}° · rock γ {g} kN/m³",
|
||||
],
|
||||
E06_NoRisk: ["위험 모드 없음(Markland).", "No kinematic failure mode (Markland)."],
|
||||
E06_NoJointsHere: ["이 측점 구간에 절리군이 없습니다.", "No joint sets cover this station."],
|
||||
E06_C_Mode: ["모드", "Mode"],
|
||||
E06_C_Joints: ["절리", "Joints"],
|
||||
E06_C_Cond: ["조건", "Case"],
|
||||
E06_C_F: ["Fs", "Fs"],
|
||||
E06_C_Req: ["기준", "Req."],
|
||||
E06_Cond_dry: ["건기", "Dry"],
|
||||
E06_Cond_wet: ["우기", "Wet"],
|
||||
E06_Cond_seismic: ["지진", "Seismic"],
|
||||
E06_NotFormed: ["(블록 안 생김)", "(no block)"],
|
||||
E06_Persist: ["(지속성 < 활동면)", "(persistence < plane)"],
|
||||
E06_ToppleNote: [
|
||||
"전도 = 기하 판정만(블록 전도 안전율은 Goodman-Bray 정밀 검토 몫).",
|
||||
"Toppling = kinematic only (block toppling FS needs a Goodman–Bray check).",
|
||||
],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
@@ -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