Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-25 23:16:09 +09:00
45 changed files with 6380 additions and 150 deletions
+9 -2
View File
@@ -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: ["🪨", "📐", "🧊", "📄", "⛰️", "🗺️"],
},
];
+3 -3
View File
@@ -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(),
+14 -7
View File
@@ -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,
+13 -6
View File
@@ -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();
+1 -1
View File
@@ -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);
+16
View File
@@ -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,
+78
View File
@@ -0,0 +1,78 @@
"""E05 보고서 s7 광역요약 — E07 광역 자연사면 위험 저장본 → 시트 (PLAN 536-2).
칸 자리 = `resources/master_template/report/stab_report_slots.md` s7.
E07 저장본이 없으면 「광역 해석 전」.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, RISK_CLASS
from E07_StabArea.E07_StabArea_Router import RESULT, load
CLASS_NAMES = ("", "안정", "보통 안정", "준안정", "위험 하한", "위험 상한", "불안정")
def stamp(project_root: Path) -> list[int] | None:
"""해석 지문용 — 저장본 크기 · 고친 때."""
path = Path(project_root) / RESULT
return [path.stat().st_size, path.stat().st_mtime_ns] if path.is_file() else None
def read(project_root: Path) -> dict[str, Any] | None:
"""저장본에서 보고서에 쓰는 것만(격자 배열 뺌)."""
doc = load(project_root)
if not doc:
return None
return {k: doc[k] for k in ("settings", "computed_at", "stations", "summary")}
def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None:
cell = dict(sheet["칸"].get(a1) or {})
cell["값"] = round(value, 6) if isinstance(value, float) else value
if style is not None:
cell["서식"] = style
sheet["칸"][a1] = cell
def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None:
"""s7 — 조건 넷 · 등급별 면적 여섯 · 위험 측점(22 행부터 한 줄씩)."""
if not area:
_set(sheet, "B5", "광역 해석 전 — E07 광역에서 [해석] 뒤 [보고서 갱신]")
return
s, summary = area["settings"], area["summary"]
_set(sheet, "B5", f"중심선 ± {s['band_m']:g} m · 격자 {s['cell_m']:g} m")
_set(
sheet,
"B6",
f"γt {s['gamma_t']:g} · γsat {s['gamma_sat']:g} kN/m³ · c {s['c']:g}~{s['c_hi']:g} kPa · "
f"φ {s['phi']:g}~{s['phi_hi']:g}° · 뿌리 점착 {s['c_root']:g} kPa · "
f"토층 {s['soil_depth_m']:g} m",
)
rain = (
"지표 포화(m = 1)"
if s["scenario"] == "saturated"
else f"강우 {s['rain_mm_h']:g} mm/h · 투수계수 {s['k_m_s']:g} m/s(정상류 포화비)"
)
_set(sheet, "B7", rain)
_set(sheet, "B8", str(area["computed_at"]).replace("T", " "))
for k in range(1, 7):
_set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]]))
rows = [r for r in area["stations"] if r.get("worst_class", 0) >= RISK_CLASS]
template = {col: dict(sheet["칸"].get(f"{col}22") or {}) for col in "ABCDEFG"}
if not rows:
_set(sheet, "A22", "위험 측점 없음")
for i, row in enumerate(rows):
r = 22 + i
for col, cell in template.items():
sheet["칸"][f"{col}{r}"] = dict(cell)
ch = float(row["chainage_m"])
_set(sheet, f"A{r}", row.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}")
_set(sheet, f"B{r}", ch)
_set(sheet, f"C{r}", float(row["fs_min"]))
_set(sheet, f"D{r}", float(row["si_min"]))
_set(sheet, f"E{r}", CLASS_NAMES[int(row["worst_class"])])
_set(sheet, f"F{r}", float(row["risk_area_m2"]))
_set(sheet, f"G{r}", float(row["slope_max_deg"]))
+94 -45
View File
@@ -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
+444
View File
@@ -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}
+446
View File
@@ -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}
+204
View File
@@ -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)
+137
View File
@@ -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,
}
+304
View File
@@ -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];
}
+397
View File
@@ -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"),
}),
);
}
})();
}
+219
View File
@@ -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;
}
+193
View File
@@ -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;
}
+485
View File
@@ -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%);
}
+16
View File
@@ -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,
};
+283
View File
@@ -0,0 +1,283 @@
"""E07 광역 자연사면 위험 — 무한사면 안전율 · SINMAP 안정 지수 (요구 345 · 351 · PLAN 536-2).
순수 계산(storage 모름) — D 예정노선 재해 안전성도 같은 엔진을 씀(한 벌).
근거 = 지식DB `original/예정노선/02` §1.1 · §2 ① · `original/안정성평가/04`(붕적토 범위).
지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D8 흐름(웅덩이 채움 · 수신 셀 = B04 배수유역
함수 재사용) · 단위 집수면적 a = 상류 셀 수 × cell²/cell(등고선 폭 = cell)
포화비 m = 1(지표 포화 · 기본) | min(R·a / (T·sinβ), 1) · T = K·z(정상류 · SINMAP · SHALSTAB)
안전율 Fs = (c + c_r + [γt z(1−m) + (γsat−γw) m z] cos²β tanφ)
/ ([γt z(1−m) + γsat m z] sinβ cosβ)
안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] 고른 분포
Fs_min ≥ 1 → SI = Fs_min · Fs_max < 1 → SI = 0
· 그 사이 → SI = P(Fs > 1)(두 고른 분포 합 · 닫힌 꼴)
등급 1 안정(SI ≥ 1.5) · 2 보통 안정(≥ 1.25) · 3 준안정(≥ 1.0) · 4 위험 하한(≥ 0.5)
· 5 위험 상한(> 0) · 6 불안정(SI = 0) · 0 = 자료 없음
격자 꼴 = 행 0 이 남쪽(y0) · 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) — E04 래스터와 같음.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
import numpy as np
from scipy.interpolate import RegularGridInterpolator
from scipy.spatial import cKDTree
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
compute_receivers,
condition_surface,
)
GAMMA_W = 9.81 # kN/m³
SI_EDGES = (1.5, 1.25, 1.0, 0.5) # 등급 1~4 아래 경계 · 5 = (0, 0.5) · 6 = 0
CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended")
RISK_CLASS = 4 # 이 등급부터 위험(SI < 1)
FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞)
@dataclass(frozen=True)
class AreaParams:
"""한 번 해석 조건 — 단위 kN/m³ · kPa · ° · m · mm/h · m/s."""
gamma_t: float
gamma_sat: float
c: float # 점착력 아래(결정 Fs · SI 범위 아래)
c_hi: float # SI 범위 위
phi: float
phi_hi: float
c_root: float = 0.0 # 뿌리 점착(나지 = 0)
soil_depth_m: float = 1.5
scenario: str = "saturated" # saturated = 지표 포화 m = 1 · rain = 정상류 강우
rain_mm_h: float = 30.0
k_m_s: float = 1e-4 # 포화 투수계수
# ---------------------------------------------------------------------------
# 지형
# ---------------------------------------------------------------------------
def resample_dem(dem: dict[str, Any], cell_m: float) -> tuple[np.ndarray, float, float]:
"""DTM npz(x · y 오름 축 · z (ny, nx) · valid_mask) → 고른 격자 (z, x0, y0) · 밖 = NaN."""
x = np.asarray(dem["x"], float)
y = np.asarray(dem["y"], float)
z = np.where(np.asarray(dem["valid_mask"], bool), np.asarray(dem["z"], float), np.nan)
nx = int(math.floor((x[-1] - x[0]) / cell_m)) + 1
ny = int(math.floor((y[-1] - y[0]) / cell_m)) + 1
gx = x[0] + np.arange(nx) * cell_m
gy = y[0] + np.arange(ny) * cell_m
yy, xx = np.meshgrid(gy, gx, indexing="ij")
f = RegularGridInterpolator((y, x), z, bounds_error=False, fill_value=np.nan)
return f(np.column_stack([yy.ravel(), xx.ravel()])).reshape(ny, nx), float(x[0]), float(y[0])
def slope_rad(z: np.ndarray, cell_m: float) -> np.ndarray:
"""Horn 3×3 경사(rad) — 이웃에 NaN 이 있거나 격자 끝이면 NaN."""
p = np.pad(z, 1, constant_values=np.nan)
def at(dy: int, dx: int) -> np.ndarray:
return p[1 + dy : 1 + dy + z.shape[0], 1 + dx : 1 + dx + z.shape[1]]
gx = ((at(-1, 1) + 2 * at(0, 1) + at(1, 1)) - (at(-1, -1) + 2 * at(0, -1) + at(1, -1))) / (
8 * cell_m
)
gy = ((at(1, -1) + 2 * at(1, 0) + at(1, 1)) - (at(-1, -1) + 2 * at(-1, 0) + at(-1, 1))) / (
8 * cell_m
)
return np.arctan(np.hypot(gx, gy))
def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray:
"""D8 단위 집수면적 a(m) = 자기 포함 상류 셀 수 × cell. 밖 = NaN.
ponytail: 지형 격자 끝 밖 상류는 모름(복사 DTM = 노선 띠) — 띠 끝 셀은 a 가 작게 나옴 ·
넓은 DEM(D 예정노선 등)이면 그대로 맞음.
"""
filled, valid = condition_surface(z.astype(np.float32))
spec = SimpleNamespace(n_rows=z.shape[0], n_cols=z.shape[1], cell_m=cell_m)
receiver, _ = compute_receivers(spec, filled, valid)
flat = filled.ravel()
order = np.flatnonzero(valid.ravel())
order = order[np.argsort(-flat[order], kind="stable")].tolist() # 높은 데서 낮은 데로
acc = valid.ravel().astype(float).tolist()
recv = receiver.tolist()
for i in order:
r = recv[i]
if r != i:
acc[r] += acc[i]
out = np.asarray(acc).reshape(z.shape) * cell_m
return np.where(valid, out, np.nan)
# ---------------------------------------------------------------------------
# 무한사면 · SINMAP
# ---------------------------------------------------------------------------
def wetness(beta: np.ndarray, a: np.ndarray, p: AreaParams) -> np.ndarray:
"""포화비 m(0~1) — 지표 포화 = 1 · 강우 = min(R·a/(T sinβ), 1)."""
if p.scenario == "saturated":
return np.ones_like(beta)
recharge = p.rain_mm_h / 1000.0 / 3600.0 # m/s
transmissivity = p.k_m_s * p.soil_depth_m # m²/s
with np.errstate(divide="ignore", invalid="ignore"):
m = recharge * a / (transmissivity * np.sin(beta))
return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0)
def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]:
"""Fs = (c + c_r)·A + tanφ·B 의 A · B(평평한 셀 = ∞)."""
z = p.soil_depth_m
cos, sin = np.cos(beta), np.sin(beta)
normal = (p.gamma_t * z * (1 - m) + (p.gamma_sat - GAMMA_W) * m * z) * cos**2
shear = (p.gamma_t * z * (1 - m) + p.gamma_sat * m * z) * sin * cos
flat = beta < FLAT_SLOPE_RAD
with np.errstate(divide="ignore", invalid="ignore"):
a = np.where(flat, np.inf, 1.0 / shear)
b = np.where(flat, np.inf, normal / shear)
return a, b
def infinite_slope_fs(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray:
"""결정 안전율(c · φ = 범위 아래 = 보수) — 지식DB 02 §1.1 식."""
a, b = _terms(beta, m, p)
with np.errstate(invalid="ignore"):
return np.where(
np.isinf(a), np.inf, (p.c + p.c_root) * a + math.tan(math.radians(p.phi)) * b
)
def _cdf_sum_uniform(
s: np.ndarray, a1: np.ndarray, a2: np.ndarray, b1: np.ndarray, b2: np.ndarray
) -> np.ndarray:
"""P(X + Y ≤ s) · X ~ U[a1, a2] · Y ~ U[b1, b2] 독립 — 폭 0 이면 그 변수는 상수."""
wa, wb = a2 - a1, b2 - b1
eps = 1e-12
def g(u: np.ndarray) -> np.ndarray:
return np.maximum(u, 0.0) ** 2 / 2
with np.errstate(divide="ignore", invalid="ignore"):
both = (g(s - a1 - b1) - g(s - a2 - b1) - g(s - a1 - b2) + g(s - a2 - b2)) / (wa * wb)
only_a = (s - a1 - b1) / wa
only_b = (s - a1 - b1) / wb
step = (a1 + b1 <= s).astype(float)
out = np.where(
(wa > eps) & (wb > eps), both, np.where(wa > eps, only_a, np.where(wb > eps, only_b, step))
)
return np.clip(np.nan_to_num(out, nan=0.0), 0.0, 1.0)
def stability_index(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray:
"""SINMAP 안정 지수 — Fs_min ≥ 1 → Fs_min · Fs_max < 1 → 0 · 그 사이 → P(Fs > 1)."""
a, b = _terms(beta, m, p)
t1, t2 = math.tan(math.radians(p.phi)), math.tan(math.radians(max(p.phi_hi, p.phi)))
c1, c2 = p.c + p.c_root, max(p.c_hi, p.c) + p.c_root
flat = np.isinf(a)
a = np.where(flat, 0.0, a)
b = np.where(flat, 0.0, b)
lo, hi = c1 * a + t1 * b, c2 * a + t2 * b
prob = 1.0 - _cdf_sum_uniform(np.ones_like(a), c1 * a, c2 * a, t1 * b, t2 * b)
si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob))
return np.where(flat, np.inf, si)
def classify(si: np.ndarray) -> np.ndarray:
"""SI → 등급 1~6(0 = 자료 없음 · NaN)."""
cls = np.full(si.shape, 6, dtype=np.uint8)
cls[si > 0] = 5
for k, edge in enumerate(reversed(SI_EDGES)): # 0.5 → 4 · 1.0 → 3 · 1.25 → 2 · 1.5 → 1
cls[si >= edge] = 4 - k
cls[np.isnan(si)] = 0
return cls
def analyze_grid(z: np.ndarray, cell_m: float, p: AreaParams) -> dict[str, np.ndarray]:
"""고른 격자 지형 → {slope(rad) · a · m · fs · si · cls} · 밖 = NaN / 0."""
beta = slope_rad(z, cell_m)
a = catchment_area(z, cell_m) if p.scenario == "rain" else np.full(z.shape, np.nan)
m = wetness(beta, a, p)
fs = infinite_slope_fs(beta, m, p)
si = stability_index(beta, m, p)
nodata = np.isnan(beta)
fs[nodata] = np.nan
si[nodata] = np.nan
m = np.where(nodata, np.nan, m)
return {"slope": beta, "a": a, "m": m, "fs": fs, "si": si, "cls": classify(si)}
# ---------------------------------------------------------------------------
# 노선 띠 · 측점
# ---------------------------------------------------------------------------
def dense_line(line: np.ndarray, step_m: float = 0.5) -> np.ndarray:
"""중심선 [x, y, 누가] 를 step 간격으로 촘촘히(꺾인 점 사이 셀도 바른 누가)."""
out = [line[:1]]
for a, b in zip(line[:-1], line[1:]):
n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / step_m)))
out.append(a + (b - a) * np.linspace(0, 1, n + 1)[1:, None])
return np.vstack(out)
def band_cells(
shape: tuple[int, int], x0: float, y0: float, cell_m: float, line: np.ndarray, half_m: float
) -> tuple[np.ndarray, np.ndarray]:
"""노선 띠(중심선 ± half_m) 셀 → (띠 안, 셀의 가장 가까운 중심선 누가)."""
dense = dense_line(np.asarray(line, float))
yy, xx = np.meshgrid(
y0 + np.arange(shape[0]) * cell_m, x0 + np.arange(shape[1]) * cell_m, indexing="ij"
)
dist, at = cKDTree(dense[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()]))
return (dist <= half_m).reshape(shape), dense[at, 2].reshape(shape)
def station_table(
chainages: list[float],
cell_chain: np.ndarray,
inside: np.ndarray,
out: dict[str, np.ndarray],
cell_m: float,
) -> list[dict[str, Any]]:
"""측점마다 맡은 띠 셀(이웃 측점과 중간 누가로 나눔) —
최소 Fs · SI · 가장 나쁜 등급 · 위험 면적."""
chs = np.asarray(sorted(chainages), float)
if not len(chs):
return []
mids = (chs[:-1] + chs[1:]) / 2
owner = np.searchsorted(mids, cell_chain)
have = inside & (out["cls"] > 0)
rows = []
for k, ch in enumerate(chs):
sel = have & (owner == k)
n = int(sel.sum())
if not n:
rows.append({"chainage_m": float(ch), "cells": 0})
continue
cls = out["cls"][sel]
fs = out["fs"][sel]
si = out["si"][sel]
rows.append(
{
"chainage_m": float(ch),
"cells": n,
"fs_min": round(float(np.min(fs)), 3),
"si_min": round(float(np.min(si)), 3),
"worst_class": int(cls.max()),
"risk_area_m2": round(float((cls >= RISK_CLASS).sum()) * cell_m**2, 1),
"slope_max_deg": round(float(np.degrees(np.nanmax(out["slope"][sel]))), 1),
}
)
return rows
def class_areas(cls: np.ndarray, inside: np.ndarray, cell_m: float) -> dict[str, float]:
"""띠 안 등급별 면적 m²."""
return {
CLASS_KEYS[k]: round(float(((cls == k) & inside).sum()) * cell_m**2, 1) for k in range(1, 7)
}
+337
View File
@@ -0,0 +1,337 @@
"""E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2).
- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면
`cached` · force = 다시
- `GET …/stab/area/progress` — 진행률
- `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약)
계산 자리 = ③ 서버 단독(엔진 `E07_StabArea_Engine` · 순수). 지형 = 복사본 원지형 DTM
(노선면 합성 안 함 — 노선 위 · 아래 자연사면).
파생 저장본 = `E07_StabArea/_derived/area.json`(지문이 같으면 안 셈).
권한 = main.py `protected_with_company`(지정받은 회사 = GET 만).
격자 꼴(화면 `logic/E07_StabArea_Raster.ts` 와 같은 열쇠) — grid = {x0, y0, dx, nx, ny}
(행 0 = 남쪽) · fs · si = uint16 ×1000(65535 = 자료 없음 · 65.534 위로 자름) ·
cls = uint8 등급(0 없음 · 1~6) · slope = uint16 ×10 ° · m = uint8 ×100(255 없음)
— 모두 줄 우선 · 작은 끝 base64 · 띠 밖 셀 = 자료 없음.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
import aiomysql
import numpy as np
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from common_util.common_util_auth import require_company
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model
from E07_StabArea import E07_StabArea_Engine as engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"])
ENGINE_VERSION = "e07-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
RESULT = Path("E07_StabArea") / "_derived" / "area.json"
DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토)
RANGE_C_KPA, RANGE_PHI_DEG = (
5.0,
3.0,
) # SI 범위 위 = 아래 + 이 폭(04 붕적토 범위 c 0~10 · φ 25~30 폭의 반)
_JOBS: dict[str, dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
_IDLE = {"running": False, "percent": 0, "message": None, "error": None}
class AreaRequest(BaseModel):
band_m: float = Field(100.0, ge=10.0, le=500.0) # 노선 띠 = 중심선 ± 이 폭
cell_m: float = Field(2.0, ge=1.0, le=10.0)
material_id: str | None = None # 이름표만(값은 아래 칸)
gamma_t: float = Field(..., gt=0.0, le=30.0)
gamma_sat: float = Field(..., gt=engine.GAMMA_W, le=30.0)
c: float = Field(..., ge=0.0, le=500.0)
c_hi: float = Field(..., ge=0.0, le=500.0)
phi: float = Field(..., ge=0.0, le=60.0)
phi_hi: float = Field(..., ge=0.0, le=60.0)
c_root: float = Field(0.0, ge=0.0, le=50.0)
soil_depth_m: float = Field(1.5, ge=0.1, le=10.0)
scenario: Literal["saturated", "rain"] = "saturated"
rain_mm_h: float = Field(30.0, ge=0.0, le=300.0)
k_m_s: float = Field(1e-4, ge=1e-8, le=1e-1)
force: bool = False
@model_validator(mode="after")
def _ranges(self) -> AreaRequest:
if self.c_hi < self.c or self.phi_hi < self.phi:
raise ValueError("범위 위가 아래보다 작습니다(c · φ).")
return self
# ---------------------------------------------------------------------------
# 입력 · 지문 · 저장본
# ---------------------------------------------------------------------------
def _ground(project_root: Path) -> dict[str, Any]:
for path in (ground_model.ground_path(project_root), ground_model.initial_path(project_root)):
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
return ground_model.pick(ground_model.load_defaults())
def defaults(project_root: Path) -> dict[str, Any]:
"""기본 조건 — E02 붕적토(없으면 첫 물성) · 범위 위 = + 5 kPa · + 3°."""
materials = _ground(project_root).get("materials") or []
mat = next((m for m in materials if m.get("id") == DEFAULT_MATERIAL), None) or materials[0]
return {
**{
k: f.default
for k, f in AreaRequest.model_fields.items()
if not f.is_required() and k != "force"
},
"material_id": mat.get("id"),
"gamma_t": float(mat["gamma_t"]),
"gamma_sat": float(mat["gamma_sat"]),
"c": float(mat["c"]),
"c_hi": float(mat["c"]) + RANGE_C_KPA,
"phi": float(mat["phi"]),
"phi_hi": float(mat["phi"]) + RANGE_PHI_DEG,
}
def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str:
body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION}
text = json.dumps(body, ensure_ascii=False, sort_keys=True)
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def load(project_root: Path) -> dict[str, Any] | None:
path = Path(project_root) / RESULT
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def _route_line(root: Path) -> np.ndarray:
points = sorted(
source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"]
)
return np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float)
def _b64(values: np.ndarray, dtype: str) -> str:
return base64.b64encode(np.ascontiguousarray(values, dtype=dtype).tobytes()).decode("ascii")
def _u16(values: np.ndarray, scale: float, keep: np.ndarray) -> str:
v = np.round(np.clip(np.nan_to_num(values * scale, nan=-1.0, posinf=65534.0), -1, 65534))
return _b64(np.where(keep & (v >= 0), v, 65535), "<u2")
def run(
project_root: Path,
root: Path,
settings: dict[str, Any],
finger: str,
report: Callable[[int, str], None],
) -> dict[str, Any]:
"""해석 한 번 → 저장본 쓰고 요약 돌려줌."""
started = time.perf_counter()
cell = float(settings["cell_m"])
report(5, "지형 격자")
with np.load(source.terrain_grid_path(root)) as npz:
dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")}
z, x0, y0 = engine.resample_dem(dem, cell)
report(25, "경사 · 집수면적 · 안전율")
keys = (
"gamma_t",
"gamma_sat",
"c",
"c_hi",
"phi",
"phi_hi",
"c_root",
"soil_depth_m",
"scenario",
"rain_mm_h",
"k_m_s",
)
out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys}))
report(75, "노선 띠 · 측점")
stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"]))
inside, chain = engine.band_cells(
z.shape, x0, y0, cell, _route_line(root), float(settings["band_m"])
)
rows = engine.station_table([s["chainage_m"] for s in stations], chain, inside, out, cell)
labels = {round(float(s["chainage_m"]), 3): s.get("label") for s in stations}
for row in rows:
row["label"] = labels.get(round(row["chainage_m"], 3))
# 띠 상자로 잘라 저장
iy, ix = np.nonzero(inside)
if not len(iy):
raise ValueError("노선 띠가 지형 격자와 겹치지 않습니다.")
win = (slice(iy.min(), iy.max() + 1), slice(ix.min(), ix.max() + 1))
keep = inside[win] & (out["cls"][win] > 0)
areas = engine.class_areas(out["cls"], inside, cell)
have = inside & (out["cls"] > 0)
summary = {
"areas_m2": areas,
"band_area_m2": round(float(inside.sum()) * cell**2, 1),
"covered_area_m2": round(float(have.sum()) * cell**2, 1),
"risk_area_m2": round(
sum(v for k, v in areas.items() if engine.CLASS_KEYS.index(k) >= engine.RISK_CLASS), 1
),
"fs_min": round(float(np.min(out["fs"][have])), 3) if have.any() else None,
"risk_stations": [
r["chainage_m"] for r in rows if r.get("worst_class", 0) >= engine.RISK_CLASS
],
"stations": len(rows),
}
doc = {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(time.perf_counter() - started, 2),
"grid": {
"x0": x0 + int(ix.min()) * cell,
"y0": y0 + int(iy.min()) * cell,
"dx": cell,
"nx": int(keep.shape[1]),
"ny": int(keep.shape[0]),
},
"fs": _u16(out["fs"][win], 1000, keep),
"si": _u16(out["si"][win], 1000, keep),
"cls": _b64(np.where(keep, out["cls"][win], 0), "u1"),
"slope": _u16(np.degrees(out["slope"][win]), 10, keep),
"m": _b64(np.where(keep, np.round(np.nan_to_num(out["m"][win], nan=0) * 100), 255), "u1"),
"stations": rows,
"summary": summary,
}
report(95, "저장")
atomic_write_json(Path(project_root) / RESULT, doc)
return summary
# ---------------------------------------------------------------------------
# 길
# ---------------------------------------------------------------------------
async def _project_root(project_id: UUID) -> Path:
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT storage_path FROM projects"
" WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL",
(str(project_id),),
)
row = await cursor.fetchone()
if not row or not row["storage_path"]:
raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.")
return Path(resolve_stored_project_path(row["storage_path"]))
def _source_root(project_root: Path) -> Path:
root = source.source_dir(project_root)
if not (root / source.MANIFEST_NAME).is_file():
raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.")
return root
@router.post("/{project_id}/stab/area/analyze", response_model=None)
async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]:
"""[해석] — 지문이 같으면 `cached`(안 셈) · 다르거나 force 면 뒤에서 셈."""
pid = str(project_id)
project_root = await _project_root(project_id)
root = _source_root(project_root)
settings = payload.model_dump(exclude={"force"})
finger = fingerprint(source.manifest(root)["source_fingerprint"], settings)
saved = await asyncio.to_thread(load, project_root)
if not payload.force and saved and saved["fingerprint"] == finger:
return {"status": "cached", "summary": saved["summary"]}
if (_JOBS.get(pid) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 광역 해석이 돌고 있습니다.")
job = {**_IDLE, "running": True, "message": "시작"}
_JOBS[pid] = job
def report(percent: int, message: str) -> None:
job.update(percent=max(0, min(99, int(percent))), message=message)
async def work() -> None:
try:
await asyncio.to_thread(run, project_root, root, settings, finger, report)
job.update(percent=100, message="끝")
except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림
logger.exception("E07 광역 해석 실패: project_id=%s", pid)
job["error"] = str(error)
finally:
job["running"] = False
task = asyncio.create_task(work(), name=f"E07 area {pid}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return {"status": "accepted"}
@router.get("/{project_id}/stab/area/progress", response_model=None)
async def progress(project_id: UUID) -> dict[str, Any]:
return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)}
def _overview(project_root: Path, root: Path) -> dict[str, Any]:
saved = load(project_root)
if saved:
current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"])
saved["stale"] = saved["fingerprint"] != current
return {
"defaults": defaults(project_root),
"materials": _ground(project_root).get("materials") or [],
"line": _route_line(root).round(3).tolist(),
"stations": [
{
"chainage_m": s["chainage_m"],
"label": s["label"],
"x": s["center_x"],
"y": s["center_y"],
}
for s in sorted(source.stations(root), key=lambda s: float(s["chainage_m"]))
],
"result": saved,
}
async def _editable(project_id: UUID, session: dict[str, Any]) -> bool:
"""[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만)."""
if session.get("role") == "SYSTEM_ADMIN":
return True
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),))
row = await cursor.fetchone()
return bool(row) and row[0] == session.get("company_id")
@router.get("/{project_id}/stab/area", response_model=None)
async def area(
project_id: UUID, session: dict[str, Any] = Depends(require_company)
) -> dict[str, Any]:
project_root = await _project_root(project_id)
root = _source_root(project_root)
overview = await asyncio.to_thread(_overview, project_root, root)
return {"status": "success", "editable": await _editable(project_id, session), **overview}
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* E07_StabArea_UI_Map.ts
* E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 ·
* 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster";
export type MapLayer = "cls" | "fs";
export interface StationPoint {
chainage_m: number;
label: string | null;
x: number;
y: number;
}
export interface AreaMap {
root: HTMLElement;
setRoute: (line: number[][], stations: StationPoint[]) => void;
setBand: (halfWidthM: number) => void;
setResult: (data: Decoded | null) => void;
setLayer: (layer: MapLayer) => void;
focus: (chainage: number) => void;
dispose: () => void;
}
interface View {
cx: number;
cy: number;
scale: number; // px / m
}
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
export function createAreaMap(): AreaMap {
const root = el("div", { className: "e07-map" });
const canvas = el("canvas", { className: "e07-map__canvas" });
const tip = el("div", { className: "e07-map__tip" });
const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") });
tip.hidden = true;
root.append(canvas, tip, empty);
const ctx = canvas.getContext("2d")!;
let line: number[][] = [];
let stations: StationPoint[] = [];
let band = 100;
let data: Decoded | null = null;
let layer: MapLayer = "cls";
let image: HTMLCanvasElement | null = null;
let picked: number | null = null;
let view: View = { cx: 0, cy: 0, scale: 1 };
let width = 0;
let height = 0;
const toScreen = (x: number, y: number): [number, number] => [
width / 2 + (x - view.cx) * view.scale,
height / 2 - (y - view.cy) * view.scale,
];
const toModel = (px: number, py: number): [number, number] => [
view.cx + (px - width / 2) / view.scale,
view.cy - (py - height / 2) / view.scale,
];
function buildImage(): void {
image = null;
if (!data) return;
const { nx, ny } = data.grid;
const off = document.createElement("canvas");
off.width = nx;
off.height = ny;
off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0);
image = off;
}
function fit(): void {
const xs = line.map((p) => p[0]);
const ys = line.map((p) => p[1]);
if (!xs.length || !width || !height) return;
const pad = band;
const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad];
const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad];
const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92;
view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale };
}
function path(): void {
ctx.beginPath();
line.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
}
function drawRoute(text: string, accent: string): void {
if (line.length < 2) return;
ctx.lineJoin = ctx.lineCap = "round";
// 노선 띠 — 중심선 ± band(옅은 띠)
ctx.globalAlpha = 0.12;
ctx.strokeStyle = accent;
ctx.lineWidth = Math.max(2 * band * view.scale, 1);
path();
ctx.stroke();
ctx.globalAlpha = 1;
ctx.strokeStyle = text;
ctx.lineWidth = 2;
path();
ctx.stroke();
const gap = stations.length > 1 ? 20 * view.scale : 999;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
stations.forEach((s, i) => {
const [px, py] = toScreen(s.x, s.y);
if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return;
const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6;
ctx.fillStyle = on ? accent : text;
ctx.beginPath();
ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9);
});
}
function drawFurniture(text: string): void {
ctx.fillStyle = text;
ctx.strokeStyle = text;
ctx.beginPath();
ctx.moveTo(width - 24, 14);
ctx.lineTo(width - 30, 34);
ctx.lineTo(width - 18, 34);
ctx.closePath();
ctx.fill();
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("N", width - 24, 46);
const target = 100 / view.scale;
const pow = 10 ** Math.floor(Math.log10(target));
const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target;
const px = meters * view.scale;
ctx.fillStyle = css("--color-surface", "#ffffff");
ctx.globalAlpha = 0.85;
ctx.fillRect(width - 24 - px, height - 42, px + 16, 34);
ctx.globalAlpha = 1;
ctx.fillStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(width - 16 - px, height - 16);
ctx.lineTo(width - 16, height - 16);
ctx.stroke();
ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26);
ctx.textAlign = "start";
}
function draw(): void {
const dpr = window.devicePixelRatio || 1;
width = root.clientWidth;
height = root.clientHeight;
if (!width || !height) return;
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const text = css("--color-text", "#1f2937");
const accent = css("--color-primary", "#6d28d9");
if (data && image) {
const g = data.grid;
const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale);
}
drawRoute(text, accent);
drawFurniture(text);
empty.hidden = Boolean(data);
}
let drag: { x: number; y: number; view: View } | null = null;
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const k = Math.exp(-event.deltaY * 0.0015);
view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k };
draw();
});
canvas.addEventListener("pointerdown", (event) => {
drag = { x: event.clientX, y: event.clientY, view: { ...view } };
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointerup", () => (drag = null));
canvas.addEventListener("dblclick", () => {
fit();
draw();
});
canvas.addEventListener("pointerleave", () => (tip.hidden = true));
canvas.addEventListener("pointermove", (event) => {
if (drag) {
view = {
...drag.view,
cx: drag.view.cx - (event.clientX - drag.x) / view.scale,
cy: drag.view.cy + (event.clientY - drag.y) / view.scale,
};
draw();
return;
}
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const i = data ? cellIndex(data.grid, mx, my) : -1;
if (i < 0 || !data!.cls[i]) return void (tip.hidden = true);
const near = stations.reduce<StationPoint | null>(
(best, s) =>
!best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2
? s
: best,
null,
);
const m = data!.m[i];
tip.textContent = [
t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey),
`SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`,
`${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`,
near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
]
.filter(Boolean)
.join("\n");
tip.style.left = `${event.clientX - rect.left + 14}px`;
tip.style.top = `${event.clientY - rect.top + 14}px`;
tip.hidden = false;
});
const resize = new ResizeObserver(() => {
const first = !width;
draw();
if (first) {
fit();
draw();
}
});
resize.observe(root);
const theme = new MutationObserver(draw);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 격자 · 층
const probe = {
project: (x: number, y: number) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = toScreen(x, y);
return { x: rect.left + px, y: rect.top + py };
},
data: () => data,
layer: () => layer,
};
(window as unknown as { __e07Map?: typeof probe }).__e07Map = probe;
return {
root,
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
fit();
draw();
},
setBand(halfWidthM) {
band = halfWidthM;
draw();
},
setResult(next) {
data = next;
buildImage();
draw();
},
setLayer(next) {
layer = next;
buildImage();
draw();
},
focus(chainage) {
picked = chainage;
const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6);
if (s) view = { ...view, cx: s.x, cy: s.y };
draw();
},
dispose() {
resize.disconnect();
theme.disconnect();
if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe)
delete (window as unknown as { __e07Map?: unknown }).__e07Map;
},
};
}
+478
View File
@@ -0,0 +1,478 @@
/* =============================================================================
* E07_StabArea_UI_Page.ts
* E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow).
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 강우(지표 포화 · 강우 강도) · 노선 띠 · [해석] · 진행률
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage.
* ========================================================================== */
import "./E07_StabArea_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
CLASS_COLORS,
CLASS_KEYS,
FS_STEPS,
RISK_CLASS,
decode,
type AreaResult,
} from "./logic/E07_StabArea_Raster";
interface Material {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
}
type Settings = Record<string, number | string | null>;
interface Overview {
editable: boolean;
defaults: Settings;
materials: Material[];
line: number[][];
stations: StationPoint[];
result: AreaResult | null;
}
interface Progress {
running: boolean;
percent: number;
message: string | null;
error: string | null;
}
const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [
["gamma_t", "E07_GammaT", "soil"],
["gamma_sat", "E07_GammaSat", "soil"],
["c", "E07_C", "soil"],
["c_hi", "E07_CHi", "soil"],
["phi", "E07_Phi", "soil"],
["phi_hi", "E07_PhiHi", "soil"],
["c_root", "E07_CRoot", "soil"],
["soil_depth_m", "E07_Depth", "soil"],
["rain_mm_h", "E07_Rain", "rain"],
["k_m_s", "E07_K", "rain"],
["band_m", "E07_Band", "band"],
["cell_m", "E07_Cell", "band"],
];
const cacheKey = (projectId: string): string => `e07_settings:${projectId}`;
const shortLabel = (label: string | null, ch: number): string =>
(label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, "");
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
const text = Array.isArray(detail)
? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" · ")
: detail;
throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`);
}
return body;
}
function swatch(color: string): HTMLElement {
const chip = el("i", { className: "e07-chip" });
chip.style.background = color;
return chip;
}
function legend(layer: MapLayer): HTMLElement {
const box = el("div", { className: "e07-legend" });
box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") }));
const rows =
layer === "cls"
? CLASS_KEYS.map((k, i) => ({
color: CLASS_COLORS[i],
label: t(`E07_Class_${k}` as LocaleKey),
}))
: FS_STEPS.map((s, i) => ({
color: s.color,
label:
i === 0
? `< ${s.below.toFixed(1)}`
: s.below === Infinity
? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}`
: `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`,
}));
for (const row of rows) {
const item = el("span", { className: "e07-legend__row" });
item.append(swatch(row.color), el("span", { text: row.label }));
box.append(item);
}
return box;
}
export function renderE07StabArea(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e07-panel" });
const main = el("div", { className: "e07-page" });
mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") }));
// ---- 가운데 ----
const map = createAreaMap();
let layer: MapLayer = "cls";
const legendSlot = el("div", { className: "e07-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
const toggle = el("div", { className: "e07-toggle" });
const layerButtons = (
[
["E07_Layer_Class", "cls"],
["E07_Layer_FS", "fs"],
] as [LocaleKey, MapLayer][]
).map(([key, value], i) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = value;
button.classList.toggle("is-on", i === 0);
button.addEventListener("click", () => {
layerButtons.forEach((b) => b.classList.toggle("is-on", b === button));
layer = value;
map.setLayer(layer);
paintLegend();
});
return button;
});
toggle.append(...layerButtons);
const stage = el("div", { className: "e07-stage" });
stage.append(map.root, legendSlot);
const center = el("div", { className: "e07-center" });
center.append(toggle, stage);
paintLegend();
// ---- 우 ----
const resultBody = el("div", { className: "e07-result" });
const side = createSidePanels({
prefKey: "e07-stab-area-result",
panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }],
defaultOpen: "result",
defaultWidth: 380,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e07-row" });
row.append(center, side.root);
main.append(row);
// ---- 좌 ----
const fields = new Map<string, InputFieldHandle>();
const cards = {
soil: createPanelCard(t("E07_Card_Soil"), false),
rain: createPanelCard(t("E07_Card_Rain"), false),
band: createPanelCard(t("E07_Card_Band"), false),
};
const material = createSelectField({ label: t("E07_Material"), options: [], compact: true });
const scenario = createSelectField({
label: t("E07_Scenario"),
options: [
{ value: "saturated", text: t("E07_Scenario_saturated") },
{ value: "rain", text: t("E07_Scenario_rain") },
],
compact: true,
});
cards.soil.append(material.root);
cards.rain.append(scenario.root);
for (const [key, label, card] of NUMBER_FIELDS) {
const field = createInputField({ label: t(label), type: "number", compact: true });
field.input.step = "any";
field.input.dataset.key = key;
fields.set(key, field);
cards[card].append(field.root);
}
const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e07-progress__text" });
const progressBox = el("div", { className: "e07-progress-box" });
progressBox.append(progressBar, progressText);
progressBox.hidden = true;
const defaultsButton = createButton({
label: t("E07_Defaults"),
variant: "ghost",
onClick: () => overview && write(overview.defaults),
});
const analyzeButton = createButton({
label: t("E07_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
});
const reanalyzeButton = createButton({
label: t("E07_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") });
readOnly.hidden = true;
panel.append(
el("p", { className: "e07-hint", text: t("E07_Hint") }),
readOnly,
cards.soil,
cards.rain,
cards.band,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
let overview: Overview | null = null;
let running = false;
function read(): Settings {
const out: Settings = { material_id: material.select.value || null };
out.scenario = scenario.select.value;
for (const [key, field] of fields) out[key] = Number(field.input.value);
return out;
}
function write(values: Settings): void {
for (const [key, field] of fields) field.input.value = String(values[key] ?? "");
material.select.value = String(values.material_id ?? "");
scenario.select.value = String(values.scenario ?? "saturated");
paintScenario();
remember();
}
function paintScenario(): void {
const rain = scenario.select.value === "rain";
for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain;
map.setBand(Number(fields.get("band_m")!.input.value) || 100);
}
const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read()));
panel.addEventListener("input", remember);
scenario.select.addEventListener("change", () => {
paintScenario();
remember();
});
fields.get("band_m")!.input.addEventListener("input", paintScenario);
material.select.addEventListener("change", () => {
const m = overview?.materials.find((x) => x.id === material.select.value);
if (!m) return;
const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v));
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
set("gamma_t", m.gamma_t);
set("gamma_sat", m.gamma_sat);
set("c", m.c);
set("c_hi", m.c + rangeC);
set("phi", m.phi);
set("phi_hi", m.phi + rangePhi);
remember();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintResult(): void {
const result = overview?.result;
if (!result) {
resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") }));
return;
}
const s = result.summary;
const list = el("dl", { className: "e07-summary" });
const pairs: [LocaleKey, string][] = [
["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)],
["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`],
["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`],
[
"E07_Sum_Covered",
`${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`,
],
[
"E07_Sum_Time",
`${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`,
],
];
for (const [key, value] of pairs)
list.append(
el("dt", { text: t(key) }),
el("dd", { text: value, attrs: { "data-field": key } }),
);
const areas = el("table", { className: "e07-table e07-areas" });
const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1;
areas.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Class") }),
el("th", { text: t("E07_Col_Area") }),
el("th", { text: "%" }),
],
}),
...CLASS_KEYS.map((k, i) => {
const name = el("td");
name.append(
swatch(CLASS_COLORS[i]),
el("span", { text: t(`E07_Class_${k}` as LocaleKey) }),
);
return el("tr", {
attrs: { "data-class": k },
children: [
name,
el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }),
el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }),
],
});
}),
);
const table = el("table", { className: "e07-table e07-stations" });
table.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Station") }),
el("th", { text: t("E07_Col_FsMin") }),
el("th", { text: t("E07_Col_SiMin") }),
el("th", { text: t("E07_Col_Worst") }),
el("th", { text: t("E07_Col_RiskArea") }),
],
}),
...result.stations.map((r) => {
const worst = el("td");
if (r.worst_class)
worst.append(
swatch(CLASS_COLORS[r.worst_class - 1]),
el("span", { text: t(`E07_Class_${CLASS_KEYS[r.worst_class - 1]}` as LocaleKey) }),
);
const tr = el("tr", {
className: (r.worst_class ?? 0) >= RISK_CLASS ? "is-risk" : "",
attrs: { "data-chainage": String(r.chainage_m) },
children: [
el("td", { text: shortLabel(r.label, r.chainage_m) }),
el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }),
el("td", { text: r.si_min === undefined ? "-" : r.si_min.toFixed(2) }),
worst,
el("td", {
text:
r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(),
}),
],
});
tr.addEventListener("click", () => {
table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on"));
tr.classList.add("is-on");
map.focus(r.chainage_m);
});
return tr;
}),
);
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") }));
notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") }));
resultBody.replaceChildren(
list,
el("h4", { text: t("E07_Areas_Title") }),
areas,
el("h4", { text: t("E07_Stations_Title") }),
table,
...notes,
);
}
async function refresh(): Promise<void> {
overview = await api<Overview>(`/projects/${projectId}/stab/area`);
material.setOptions(
overview.materials.map((m) => ({ value: m.id, text: m.name })),
String(overview.defaults.material_id ?? ""),
);
map.setResult(overview.result ? decode(overview.result) : null);
paintButtons();
paintResult();
}
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`;
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
else showToast(t("E07_Done"), "success");
await refresh();
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
async function analyze(force: boolean): Promise<void> {
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
method: "POST",
body: JSON.stringify({ ...read(), force }),
});
if (body.status === "cached") {
showToast(t("E07_Cached"), "info");
await refresh();
return;
}
running = true;
paintButtons();
progressBox.hidden = false;
void poll();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
// ---- 처음 ----
paintButtons();
void (async () => {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e07-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
return;
}
const ov = overview!;
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults);
map.setRoute(ov.line, ov.stations);
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (p?.running) void poll();
})();
const leave = new MutationObserver(() => {
if (root.isConnected && main.isConnected) return;
leave.disconnect();
map.dispose();
});
leave.observe(document.body, { childList: true, subtree: true });
}
+231
View File
@@ -0,0 +1,231 @@
/* =============================================================================
* E07_StabArea_UI_Style.css
* E07 광역 위험 지도 — 좌측 조건 · 진행 / 가운데 지도 · 범례 / 우측 요약 · 측점 표
* 색은 테마 토큰만(밝음 · 어두움 같이) · 등급 · 안전율 색만 logic 고정색
* ========================================================================== */
.e07-page {
height: 100dvh;
box-sizing: border-box;
}
.e07-row {
height: 100%;
}
.e07-center {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
min-height: 0;
padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12);
box-sizing: border-box;
}
.e07-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e07-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e07-stage {
position: relative;
flex: 1 1 auto;
min-height: 320px;
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
overflow: hidden;
}
.e07-map {
position: absolute;
inset: 0;
}
.e07-map__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.e07-map__tip {
position: absolute;
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
white-space: pre;
pointer-events: none;
}
.e07-map__empty {
position: absolute;
inset: auto 0 45% 0;
margin: 0;
text-align: center;
color: var(--color-text-muted);
pointer-events: none;
}
.e07-legend-slot {
position: absolute;
left: var(--spacing-8);
bottom: var(--spacing-8);
pointer-events: none;
}
.e07-legend {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
}
.e07-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e07-chip {
display: inline-block;
width: 14px;
height: 10px;
flex: none;
margin-right: var(--spacing-4);
border-radius: 2px;
border: 1px solid var(--color-border);
vertical-align: middle;
}
/* 좌측 */
.e07-hint,
.e07-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
}
.e07-panel .ui-field[hidden] {
display: none;
}
.e07-progress-box {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.e07-progress-box[hidden] {
display: none;
}
.e07-progress {
width: 100%;
}
.e07-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e07-result {
padding: var(--spacing-12);
color: var(--color-text);
}
.e07-result h4 {
margin: var(--spacing-16) 0 var(--spacing-8);
font-size: 13px;
}
.e07-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-8) var(--spacing-12);
margin: 0;
font-size: 13px;
}
.e07-summary dt {
color: var(--color-text-muted);
}
.e07-summary dd {
margin: 0;
font-weight: 600;
text-align: right;
}
.e07-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.e07-table th,
.e07-table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: right;
white-space: nowrap;
}
.e07-table th:first-child,
.e07-table td:first-child,
.e07-stations td:nth-child(4) {
text-align: left;
}
.e07-table th {
color: var(--color-text-muted);
font-weight: 600;
}
.e07-stations tr[data-chainage] {
cursor: pointer;
}
.e07-stations tr[data-chainage]:hover {
background: var(--color-surface-raised);
}
.e07-stations tr.is-risk td {
color: var(--color-danger);
font-weight: 600;
}
.e07-stations tr.is-on {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.e07-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
font-size: 12px;
}
.e07-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
+16
View File
@@ -0,0 +1,16 @@
/* =============================================================================
* E07_StabArea_Route.ts
* 길 선언(PLAN 536-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E07 광역 위험 지도 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import type { DraftRoute } from "../../A00_Common/router";
export const ROUTE: DraftRoute = {
path: ROUTES.E07_STAB_AREA,
workflow: true,
project: true,
footerless: true,
load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea,
};
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* E07_StabArea_Raster.ts
* E07 광역 위험 지도 화면 계산(PLAN 536-2) — 저장본 격자 풀기 · 등급 · 안전율 색 · RGBA · 셀 찾기.
* 격자 꼴 = 서버 `E07_StabArea_Router.py` 머리 주석(fs · si uint16 ×1000 · cls uint8 · slope ×10 ·
* m ×100 · 작은 끝 base64 · 행 0 = 남쪽). 순수 함수만(DOM · fetch 금지).
* ========================================================================== */
export interface Grid {
x0: number;
y0: number;
dx: number;
nx: number;
ny: number;
}
export interface StationRow {
chainage_m: number;
label: string | null;
cells: number;
fs_min?: number;
si_min?: number;
worst_class?: number;
risk_area_m2?: number;
slope_max_deg?: number;
}
export interface AreaSummary {
areas_m2: Record<string, number>;
band_area_m2: number;
covered_area_m2: number;
risk_area_m2: number;
fs_min: number | null;
risk_stations: number[];
stations: number;
}
export interface AreaResult {
stale?: boolean;
computed_at: string;
elapsed_s: number;
settings: Record<string, unknown>;
grid: Grid;
fs: string;
si: string;
cls: string;
slope: string;
m: string;
stations: StationRow[];
summary: AreaSummary;
}
/** 풀어 둔 격자 — 자료 없음 = NaN(fs · si · slope · m) · 0(cls) */
export interface Decoded {
grid: Grid;
fs: Float32Array;
si: Float32Array;
cls: Uint8Array;
slope: Float32Array;
m: Float32Array;
}
/** 등급 열쇠(서버 CLASS_KEYS 차례 · 1~6) */
export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const;
export const RISK_CLASS = 4;
/** 등급 색 1~6 — SINMAP 관례(초록 → 빨강) */
export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"];
/** 안전율 색 단계 — 1.0 · 1.2 · 1.5 경계 */
export const FS_STEPS: readonly { below: number; color: string }[] = [
{ below: 1.0, color: "#a50026" },
{ below: 1.2, color: "#f46d43" },
{ below: 1.5, color: "#fee08b" },
{ below: 2.0, color: "#a6d96a" },
{ below: Infinity, color: "#1a9850" },
];
function bytes(text: string): Uint8Array {
const raw = atob(text);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
return out;
}
function u16(text: string, scale: number): Float32Array {
const b = bytes(text);
const view = new DataView(b.buffer);
const out = new Float32Array(b.length / 2);
for (let i = 0; i < out.length; i += 1) {
const v = view.getUint16(i * 2, true);
out[i] = v === 65535 ? NaN : v / scale;
}
return out;
}
export function decode(result: AreaResult): Decoded {
const m = bytes(result.m);
return {
grid: result.grid,
fs: u16(result.fs, 1000),
si: u16(result.si, 1000),
cls: bytes(result.cls),
slope: u16(result.slope, 10),
m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)),
};
}
function rgb(hex: string): [number, number, number] {
const n = Number.parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function fsColor(value: number): string {
return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color;
}
/** 격자 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 자료 없음 = 투명 */
export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray<ArrayBuffer> {
const { nx, ny } = data.grid;
const out = new Uint8ClampedArray(nx * ny * 4);
const classRgb = CLASS_COLORS.map(rgb);
const fsRgb = FS_STEPS.map((s) => rgb(s.color));
for (let iy = 0; iy < ny; iy += 1) {
const row = ny - 1 - iy;
for (let ix = 0; ix < nx; ix += 1) {
const i = iy * nx + ix;
const k = data.cls[i];
if (!k) continue;
const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below);
const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j];
const at = (row * nx + ix) * 4;
out[at] = color[0];
out[at + 1] = color[1];
out[at + 2] = color[2];
out[at + 3] = 255;
}
}
return out;
}
/** 모델 좌표 → 셀 번호(격자 밖 = -1) */
export function cellIndex(grid: Grid, x: number, y: number): number {
const ix = Math.round((x - grid.x0) / grid.dx);
const iy = Math.round((y - grid.y0) / grid.dx);
return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? -1 : iy * grid.nx + ix;
}
/** 등급별 셀 수(1~6) — 범례 · 검증용 */
export function classCounts(data: Decoded): number[] {
const out = [0, 0, 0, 0, 0, 0];
for (const k of data.cls) if (k) out[k - 1] += 1;
return out;
}
+8 -1
View File
@@ -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",
),
}
+2 -2
View File
@@ -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"],
}
+5 -1
View File
@@ -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;
+2
View File
@@ -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 항목이 보여 이걸로 실행여부를 판단하고 싶다고
+4
View File
@@ -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 이상)이 있는 측점.
+2 -2
View File
@@ -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))
+14 -10
View File
@@ -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()
+11
View File
@@ -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
-15
View File
@@ -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
+147 -50
View File
@@ -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
+426
View File
@@ -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"]
+158
View File
@@ -0,0 +1,158 @@
"""536-2 — E07 광역 자연사면 위험 `E07_StabArea_Engine` · `_Router`.
무한사면 해석해(건조 · 포화 · 점착) · SINMAP 안정 지수(닫힌 꼴 = 표본 · 등급 경계) ·
D8 집수면적 견본(기운 평면 · V 골짜기) · 강우 포화비 · 가짜 복사본으로 띠 · 측점표 · 저장본 · 지문.
"""
import base64
import json
import math
import numpy as np
import pytest
from E07_StabArea import E07_StabArea_Engine as engine
from E07_StabArea import E07_StabArea_Router as router_mod
SOIL = dict(gamma_t=18.0, gamma_sat=20.0, c=0.0, c_hi=0.0, phi=35.0, phi_hi=35.0)
def _fs(beta_deg: float, m: float, **kw) -> float:
p = engine.AreaParams(**{**SOIL, **kw})
return float(engine.infinite_slope_fs(np.array([math.radians(beta_deg)]), np.array([m]), p)[0])
def test_무한사면_해석해_건조_포화_점착():
t = math.tan(math.radians(35)) / math.tan(math.radians(30))
assert _fs(30, 0.0) == pytest.approx(t, rel=1e-12) # 건조 c = 0 → tanφ / tanβ
assert _fs(30, 1.0) == pytest.approx((20 - 9.81) / 20 * t, rel=1e-12) # 포화 → γ' / γsat 배
# 손계산 — c 5 · 뿌리 2 · z 1.5 · β 35° · m 0.5
b, z = math.radians(35), 1.5
w = 18 * z * 0.5 + 20 * z * 0.5
n = (18 * z * 0.5 + (20 - 9.81) * z * 0.5) * math.cos(b) ** 2
hand = (7 + n * math.tan(math.radians(35))) / (w * math.sin(b) * math.cos(b))
assert _fs(35, 0.5, c=5.0, c_root=2.0) == pytest.approx(hand, rel=1e-12)
assert math.isinf(_fs(0, 1.0)) # 평평 = 활동 없음
def test_SINMAP_안정지수_닫힌꼴_표본():
p = engine.AreaParams(gamma_t=17.5, gamma_sat=18.5, c=0.0, c_hi=8.0, phi=25.0, phi_hi=38.0)
beta = np.radians(np.array([10.0, 30.0, 35.0, 40.0, 60.0]))
m = np.ones_like(beta)
si = engine.stability_index(beta, m, p)
a, b = engine._terms(beta, m, p)
rng = np.random.default_rng(1)
c = rng.uniform(0, 8, 400_000)
t = rng.uniform(math.tan(math.radians(25)), math.tan(math.radians(38)), 400_000)
for k in range(len(beta)):
lo = 0 * a[k] + math.tan(math.radians(25)) * b[k]
fs = c * a[k] + t * b[k]
if lo >= 1:
assert si[k] == pytest.approx(lo) # Fs_min ≥ 1 → SI = Fs_min
elif fs.max() < 1:
assert si[k] == 0.0
else:
assert si[k] == pytest.approx((fs > 1).mean(), abs=3e-3) # P(Fs > 1)
assert si[0] >= 1 and 0 < si[1] < 1 and si[4] == 0 # 세 갈래 모두 지남
# 폭 0(범위 없음) — 결정 Fs 와 같거나 0
q = engine.AreaParams(**SOIL)
fs = engine.infinite_slope_fs(beta, m, q)
si0 = engine.stability_index(beta, m, q)
assert np.allclose(si0, np.where(fs >= 1, fs, 0.0))
def test_SINMAP_등급_경계():
si = np.array([np.inf, 1.5, 1.4999, 1.25, 1.2499, 1.0, 0.9999, 0.5, 0.4999, 1e-9, 0.0, np.nan])
assert engine.classify(si).tolist() == [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 0]
def test_D8_집수면적_기운평면_V골짜기():
cell = 2.0
ny, nx = 5, 7
xx = np.arange(nx)[None, :] * cell
plane = np.repeat(100.0 - 0.5 * xx, ny, axis=0) # 동쪽으로 기움 → 행마다 동쪽으로 흐름
a = engine.catchment_area(plane, cell)
assert np.allclose(a, (np.arange(nx)[None, :] + 1) * cell)
yy = np.arange(9)[:, None] * cell
valley = 100.0 + np.abs(yy - 8.0) * 1.0 - 0.1 * np.arange(nx)[None, :] * cell
a = engine.catchment_area(valley, cell)
assert a[4, -1] == pytest.approx(9 * nx * cell) # 전부가 골 출구로
assert np.all(a[4, :-1] < a[4, 1:]) # 골 따라 늘어남
def test_강우_포화비():
p = engine.AreaParams(**SOIL, scenario="rain", rain_mm_h=36.0, k_m_s=1e-4, soil_depth_m=2.0)
beta = np.radians(np.array([30.0, 30.0]))
a = np.array([10.0, 1e6])
m = engine.wetness(beta, a, p)
assert m[0] == pytest.approx(1e-5 * 10 / (2e-4 * 0.5)) # R a / (T sinβ)
assert m[1] == 1.0
assert engine.wetness(beta, a, engine.AreaParams(**SOIL)).tolist() == [1.0, 1.0]
def _decode(text: str, dtype: str, grid: dict) -> np.ndarray:
return np.frombuffer(base64.b64decode(text), dtype=dtype).reshape(grid["ny"], grid["nx"])
@pytest.fixture()
def project(tmp_path, monkeypatch):
"""가짜 E 복사본 — 서쪽이 낮은 30° 비탈(동서 200 m · 남북 100 m) · 노선 = 남북 중심선."""
x = np.arange(0.0, 200.5, 0.5)
y = np.arange(0.0, 100.5, 0.5)
z = np.repeat((x * math.tan(math.radians(30)))[None, :], len(y), axis=0)
valid = np.ones_like(z, bool)
valid[:, x > 180] = False # 동쪽 끝 = 지형 없음
dem = tmp_path / "dtm.npz"
np.savez(
dem,
x=x.astype(np.float32),
y=y.astype(np.float32),
z=z.astype(np.float32),
valid_mask=valid,
)
stations = [
{"chainage_m": float(c), "label": f"No.{i}", "center_x": 100.0, "center_y": float(c)}
for i, c in enumerate(range(0, 101, 20))
]
route = [
{"sequence_num": i, "model_x": 100.0, "model_y": float(c), "chainage_m": float(c)}
for i, c in enumerate((0, 100))
]
src = router_mod.source
monkeypatch.setattr(src, "terrain_grid_path", lambda _r: dem)
monkeypatch.setattr(src, "stations", lambda _r: stations)
monkeypatch.setattr(src, "db_snapshot", lambda _r: {"route_points": route})
return tmp_path
def test_길_띠_측점표_저장본(project):
settings = {**router_mod.defaults(project), "band_m": 30.0, "cell_m": 2.0}
assert settings["material_id"] == "colluvium" and settings["c_hi"] == settings["c"] + 5
steps = []
summary = router_mod.run(project, project, settings, "fp", lambda p, m: steps.append(p))
doc = router_mod.load(project)
assert steps == sorted(steps) and doc["fingerprint"] == "fp"
g = doc["grid"]
cls = _decode(doc["cls"], "u1", g)
fs = _decode(doc["fs"], "<u2", g)
# 붕적토 포화 30° — 손계산 Fs
b = math.radians(30)
n = (18.5 - 9.81) * 1.5 * math.cos(b) ** 2
hand = (5 + n * math.tan(math.radians(27))) / (18.5 * 1.5 * math.sin(b) * math.cos(b))
inner = fs[cls > 0]
assert np.median(inner) / 1000 == pytest.approx(hand, abs=2e-3)
assert summary["fs_min"] == pytest.approx(hand, abs=5e-3)
# 띠 = 중심선 ± 30 m(격자 상자도 띠만큼) · 등급 면적 합 = 덮인 면적
assert g["nx"] * g["dx"] <= 62 + 1e-9 and g["ny"] * g["dx"] <= 102 + 1e-9
assert sum(summary["areas_m2"].values()) == pytest.approx(summary["covered_area_m2"])
assert summary["covered_area_m2"] == pytest.approx((cls > 0).sum() * 4.0)
rows = doc["stations"]
assert [r["chainage_m"] for r in rows] == [0, 20, 40, 60, 80, 100]
assert all(r["label"] == f"No.{i}" and r["worst_class"] >= 4 for i, r in enumerate(rows))
assert summary["risk_stations"] == [0, 20, 40, 60, 80, 100]
assert sum(r["cells"] for r in rows) == (cls > 0).sum()
# 지문 = 설정에 따라 바뀜
assert router_mod.fingerprint("s", settings) != router_mod.fingerprint(
"s", {**settings, "c": 6}
)
json.dumps(doc) # 그대로 JSON
+5
View File
@@ -16,6 +16,7 @@
* - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11)
* - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기
* - ui_template_locale_e04.ts : E04 안정성평가 3D 해석
* - ui_template_locale_e07.ts : E07 안정성평가 광역 위험 지도
*
* 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다.
* ========================================================================== */
@@ -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;
+2
View File
@@ -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) --- */
+122
View File
@@ -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>;
+83
View File
@@ -0,0 +1,83 @@
/* =============================================================================
* ui_template_locale_e07.ts
* 다국어 사전 — E07 안정성평가 광역 자연사면 위험 지도(요구 345 · 351 · PLAN 536-2)
*
* 신규 문구는 해당 섹션 최하단에 추가.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e07 = {
/* --- E07 광역 위험 지도 --- */
E07_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
E07_Hint: [
"노선 띠 안 자연사면의 표층 붕괴 위험 — 무한사면 안전율과 SINMAP 안정 지수(SI)로 등급을 매깁니다. 지형 = 복사한 원지형.",
"Shallow-landslide hazard of natural slopes in the route band — infinite-slope Fs and SINMAP stability index (SI). Terrain = copied original DTM.",
],
E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E07_Card_Soil: ["토층 물성", "Soil layer"],
E07_Card_Rain: ["강우 · 포화", "Rain · saturation"],
E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"],
E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"],
E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"],
E07_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"],
E07_C: ["c 아래 (kPa)", "c low (kPa)"],
E07_CHi: ["c 위 (kPa)", "c high (kPa)"],
E07_Phi: ["φ 아래 (°)", "φ low (°)"],
E07_PhiHi: ["φ 위 (°)", "φ high (°)"],
E07_CRoot: ["뿌리 점착 (kPa)", "Root cohesion (kPa)"],
E07_Depth: ["토층 두께 (m)", "Soil depth (m)"],
E07_Scenario: ["시나리오", "Scenario"],
E07_Scenario_saturated: ["지표 포화 (m = 1)", "Fully saturated (m = 1)"],
E07_Scenario_rain: ["강우 강도 (정상류)", "Rainfall (steady state)"],
E07_Rain: ["강우 강도 (mm/h)", "Rainfall (mm/h)"],
E07_K: ["투수계수 (m/s)", "Hydraulic conductivity (m/s)"],
E07_Band: ["띠 폭 ± (m)", "Band half-width (m)"],
E07_Cell: ["격자 (m)", "Cell (m)"],
E07_Defaults: ["기본값", "Defaults"],
E07_Analyze: ["해석", "Analyze"],
E07_Reanalyze: ["다시 해석", "Re-analyze"],
E07_Running: ["해석 중", "Analyzing"],
E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."],
E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."],
E07_Layer_Class: ["등급 (SI)", "Class (SI)"],
E07_Layer_FS: ["안전율 Fs", "Safety factor Fs"],
E07_Legend_Class: ["SINMAP 등급", "SINMAP class"],
E07_Legend_FS: ["무한사면 Fs", "Infinite-slope Fs"],
E07_Class_stable: ["안정 (SI ≥ 1.5)", "Stable (SI ≥ 1.5)"],
E07_Class_moderate: ["보통 안정 (≥ 1.25)", "Moderately stable (≥ 1.25)"],
E07_Class_quasi: ["준안정 (≥ 1.0)", "Quasi-stable (≥ 1.0)"],
E07_Class_lower: ["위험 하한 (≥ 0.5)", "Lower threshold (≥ 0.5)"],
E07_Class_upper: ["위험 상한 (> 0)", "Upper threshold (> 0)"],
E07_Class_defended: ["불안정 (SI = 0)", "Defended (SI = 0)"],
E07_Map_Empty: [
"[해석] 을 누르면 위험 지도가 나옵니다.",
"Press [Analyze] to draw the hazard map.",
],
E07_Tip_Slope: ["경사", "Slope"],
E07_Tip_Station: ["가까운 측점", "Nearest station"],
E07_Result_Title: ["광역 결과", "Area result"],
E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."],
E07_Result_Stale: [
"엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].",
"The engine changed — re-analyze.",
],
E07_Result_Note: [
"문헌 추정치 · 현장 시험 없음. SI = c · φ 범위 안에서 Fs > 1 일 확률(SI ≥ 1 이면 가장 나쁜 값의 Fs). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",
"Literature values, no field tests. SI = probability of Fs > 1 over the c · φ ranges (worst-case Fs when SI ≥ 1). Upslope area beyond the terrain grid is not counted.",
],
E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"],
E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"],
E07_Sum_RiskStations: ["위험 측점", "Hazard stations"],
E07_Sum_Covered: ["지형 있는 띠 / 띠", "Band with terrain / band"],
E07_Sum_Time: ["해석", "Computed"],
E07_Areas_Title: ["등급별 면적", "Area by class"],
E07_Stations_Title: ["측점별 위험 (누르면 지도로)", "Hazard by station (click to locate)"],
E07_Col_Class: ["등급", "Class"],
E07_Col_Area: ["면적 (m²)", "Area (m²)"],
E07_Col_Station: ["측점", "Station"],
E07_Col_FsMin: ["최소 Fs", "Min Fs"],
E07_Col_SiMin: ["최소 SI", "Min SI"],
E07_Col_Worst: ["나쁜 등급", "Worst class"],
E07_Col_RiskArea: ["위험 m²", "Hazard m²"],
} as const satisfies Record<string, LocaleEntry>;