Files
Aislo/E04_Stab3D/E04_Stab3D_Run.py
T
eomsangdonandClaude Opus 5.5 cfd1e3eb2c feat(E04): 3D 해석 길 — analyze3d · 끊기 · progress3d · result3d · 입력 지문 파생 저장본 · 시험 (533-3 길 부분)
- 엔진 = 미리보기(지형 경사 가짜 래스터) · 533-1 · 2 결과 꼴이 오면 run_engine 만 갈아 끼움
- 권한 = protected_with_company(지정받은 회사 GET 만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:14:29 +09:00

276 lines
9.9 KiB
Python

"""E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3).
계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만.
저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 ·
요청 · 엔진 판)이 같으면 다시 셈하지 않음.
래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠):
grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열
z = 합성 지표(원지형 + 노선면) · f = 셀별 최소 안전율 · v = 그 임계 체적 m³ · m = 방법(0 Bishop
· 1 Janbu 보정) · 닿지 않은 셀 = null
best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg, f, volume, method, ix, iy}
"""
from __future__ import annotations
import hashlib
import json
import math
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
from common_util.common_util_surface_sampler import DtmGridSampler
from E01_StabRegister import E01_StabRegister_Source as source
ENGINE_VERSION = "e04-preview-1" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
STAGE_DIR = "E04_Stab3D"
GROUND_FILE = Path("E02_StabGround") / "ground.json"
SECTION_LENGTH_M = 100.0 # 자동 구간 길이
MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리)
METHOD_NAMES = ("bishop", "janbu_corrected")
Progress = Callable[[int, str], None]
StopFlag = Callable[[], bool]
def results_dir(project_root: Path) -> Path:
return Path(project_root) / STAGE_DIR / "results"
def section_key(start_m: float, end_m: float) -> str:
return f"{start_m:.2f}_{end_m:.2f}"
def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]:
"""측점을 앞에서부터 SECTION_LENGTH_M 이상 묶음 — 경계 = 측점 · 꼬리가 짧으면 앞 구간에 붙임."""
chainages = sorted({float(s["chainage_m"]) for s in stations})
out: list[dict[str, float]] = []
start = chainages[0] if chainages else 0.0
for ch in chainages[1:]:
if ch - start >= SECTION_LENGTH_M - 1e-6:
out.append({"start_m": start, "end_m": ch})
start = ch
if chainages and start < chainages[-1]:
if out and chainages[-1] - start < SECTION_LENGTH_M / 2:
out[-1]["end_m"] = chainages[-1]
else:
out.append({"start_m": start, "end_m": chainages[-1]})
return out
def read_ground(project_root: Path) -> dict[str, Any] | None:
"""E02 작업본(없으면 None = 기본값)."""
path = Path(project_root) / GROUND_FILE
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def fingerprint(
source_fingerprint: str, ground: dict[str, Any] | None, settings: dict[str, Any]
) -> str:
body = json.dumps(
{
"source": source_fingerprint,
"ground": ground,
"settings": settings,
"engine": ENGINE_VERSION,
},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha1(body.encode("utf-8")).hexdigest()
def section_stations(stations: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]:
return [s for s in stations if start_m - 1e-6 <= float(s["chainage_m"]) <= end_m + 1e-6]
def bbox_of(
stations: list[dict[str, Any]], half_width_m: float
) -> tuple[float, float, float, float]:
xs = [float(s["center_x"]) for s in stations]
ys = [float(s["center_y"]) for s in stations]
return (
min(xs) - half_width_m,
max(xs) + half_width_m,
min(ys) - half_width_m,
max(ys) + half_width_m,
)
# ---------------------------------------------------------------------------
# 엔진 — 533-1 · 2 결과 꼴이 오기 전 = 미리보기(지형 경사로 만든 가짜 래스터 · 틀 · 배치 확인용)
# ---------------------------------------------------------------------------
def _preview_engine(
root: Path,
stations: list[dict[str, Any]],
settings: dict[str, Any],
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
dem = DtmGridSampler.open_cached(source.terrain_grid_path(root))
x0, x1, y0, y1 = bbox_of(stations, float(settings["box_half_width_m"]))
dx = float(settings["cell_m"])
xs = np.arange(x0, x1 + dx / 2, dx)
ys = np.arange(y0, y1 + dx / 2, dx)
xx, yy = np.meshgrid(xs, ys)
z, valid = dem.sample_xy(np.column_stack([xx.ravel(), yy.ravel()]))
z = np.where(valid, z, np.nan).reshape(xx.shape)
gy, gx = np.gradient(z, dx)
slope = np.hypot(gx, gy)
f = np.full(z.shape, np.nan)
rows = z.shape[0]
for iy in range(rows):
if should_stop():
raise InterruptedError
f[iy] = 0.8 + 2.4 / (1.0 + 2.5 * slope[iy] ** 2)
if iy % max(1, rows // 20) == 0:
on_progress(int(100 * iy / rows), f"미리보기 {iy}/{rows} 줄")
volume = np.where(np.isfinite(f), 50.0 + 400.0 * slope**2, np.nan)
iy, ix = np.unravel_index(int(np.nanargmin(f)), f.shape)
r = 10.0
best = {
"x": float(xs[ix]),
"y": float(ys[iy]),
"z": float(z[iy, ix] + 0.6 * r),
"r": r,
"ratio": 1.0,
"azimuth_deg": 0.0,
"f": float(f[iy, ix]),
"volume": float(volume[iy, ix]),
"method": 0,
}
return {
"grid": {"x0": float(xs[0]), "y0": float(ys[0]), "dx": dx, "nx": len(xs), "ny": len(ys)},
"z": z,
"f": f,
"v": volume,
"m": np.where(np.isfinite(f), 0.0, np.nan),
"best": best,
"trials": int(np.isfinite(f).sum()),
"preview": True,
}
def run_engine(
root: Path,
stations: list[dict[str, Any]],
ground: dict[str, Any] | None,
settings: dict[str, Any],
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
"""엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}."""
return _preview_engine(root, stations, settings, on_progress, should_stop)
# ---------------------------------------------------------------------------
# 저장본
# ---------------------------------------------------------------------------
def _flat(values: np.ndarray, digits: int) -> list[float | None]:
return [None if not math.isfinite(v) else round(float(v), digits) for v in values.ravel()]
def _nearest_chainage(stations: list[dict[str, Any]], x: float, y: float) -> float:
return min(
stations, key=lambda s: (float(s["center_x"]) - x) ** 2 + (float(s["center_y"]) - y) ** 2
)["chainage_m"]
def pack(
raw: dict[str, Any],
stations: list[dict[str, Any]],
settings: dict[str, Any],
finger: str,
elapsed_s: float,
) -> dict[str, Any]:
"""엔진 결과 → 저장본(JSON) — 요약은 best 에서."""
grid = raw["grid"]
best = dict(raw["best"])
best["ix"] = int(round((best["x"] - grid["x0"]) / grid["dx"]))
best["iy"] = int(round((best["y"] - grid["y0"]) / grid["dx"]))
f = np.asarray(raw["f"], dtype=float)
return {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"preview": bool(raw.get("preview")),
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(elapsed_s, 2),
"grid": grid,
"z": _flat(np.asarray(raw["z"], dtype=float), 2),
"f": _flat(f, 3),
"v": _flat(np.asarray(raw["v"], dtype=float), 1),
"m": _flat(np.asarray(raw["m"], dtype=float), 0),
"best": best,
"summary": {
"start_m": settings["start_m"],
"end_m": settings["end_m"],
"f_min": round(float(best["f"]), 3),
"volume": round(float(best["volume"]), 1),
"method": METHOD_NAMES[int(best.get("method") or 0)],
"chainage_m": float(_nearest_chainage(stations, best["x"], best["y"])),
"cells": int(np.isfinite(f).sum()),
"trials": int(raw.get("trials") or 0),
},
}
def save(project_root: Path, key: str, result: dict[str, Any]) -> None:
folder = results_dir(project_root)
folder.mkdir(parents=True, exist_ok=True)
tmp = folder / f"{key}.json.tmp"
tmp.write_text(json.dumps(result, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
tmp.replace(folder / f"{key}.json")
def load(project_root: Path, key: str) -> dict[str, Any] | None:
path = results_dir(project_root) / f"{key}.json"
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def saved_summaries(project_root: Path) -> list[dict[str, Any]]:
"""저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용."""
out = []
for path in sorted(results_dir(project_root).glob("*.json")):
doc = json.loads(path.read_text(encoding="utf-8"))
out.append(
{
"key": path.stem,
"fingerprint": doc["fingerprint"],
"settings": doc["settings"],
"preview": doc.get("preview", False),
**doc["summary"],
}
)
return out
def analyze(
project_root: Path,
root: Path,
settings: dict[str, Any],
finger: str,
on_progress: Progress,
should_stop: StopFlag,
) -> dict[str, Any]:
"""한 구간 3D 해석 → 저장본 쓰고 요약 돌려줌."""
started = time.perf_counter()
stations = source.stations(root)
picked = section_stations(stations, settings["start_m"], settings["end_m"])
if len(picked) < 2:
raise ValueError("구간 안 측점이 둘 미만입니다.")
ground = read_ground(project_root)
raw = run_engine(root, picked, ground, settings, on_progress, should_stop)
result = pack(raw, picked, settings, finger, time.perf_counter() - started)
save(project_root, section_key(settings["start_m"], settings["end_m"]), result)
return result["summary"]