- 길: asyncio.to_thread(build.save · refresh · reset) → store(나눈 storage 모듈) — PUT 500 고침 - 시트 서명: 수를 float 로 맞춤 — 화면(JS)을 거친 10.0 → 10 을 사용자 고침으로 잘못 알리던 덮임 알림 고침 - 화면: 본문 칸이 min-height 뿐이라 내장 엑셀 격자 높이 0 — 본문 높이 calc(100dvh - 머리) - 그림: 우기 · 지진 임계면 점선(건기와 겹쳐도 보임) · 해석 방법 글 짧게(Bishop) - 시험: 길(TestClient — 판 409 · 저장 · 갱신 · 그림 · xlsx · 초기화) · 서명 화면 왕복 같음 — 7 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
605 lines
25 KiB
Python
605 lines
25 KiB
Python
"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
|
|
|
|
입력 = E02 지반 조건(`ground.json`) · E03 2D 결과 요약(`_derived/result2d.json`) · 측점별 저장본 ·
|
|
대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E01 복사본(측점 · 횡단 · 노선).
|
|
양식 = `resources/master_template/report/stab_report.json`
|
|
칸 자리 = 같은 폴더 `stab_report_slots.md`.
|
|
|
|
몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 ·
|
|
프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6)
|
|
= [보고서 갱신] 때 양식에서 통째로 다시 씀.
|
|
판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임
|
|
(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음).
|
|
|
|
storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`.
|
|
|
|
계산 자리 = ③ 서버 단독(값 셈 없음 · 결과 옮겨 적기 · 수식은 공용 Node 엔진).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from E01_StabRegister import E01_StabRegister_Source as source
|
|
from E02_StabGround import E02_StabGround_Model as ground_model
|
|
from E03_StabSection.E03_StabSection_Engine_Model import final_surface
|
|
from E04_Stab3D import E04_Stab3D_Run as run3d
|
|
from E05_StabReport import E05_StabReport_Figure as figure
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json"
|
|
STAGE = "E05_StabReport"
|
|
REPORT, META = "report.json", "report_meta.json"
|
|
USER_SHEETS = ("s1", "s2")
|
|
PROGRAM_SHEETS = ("s3", "s4", "s5", "s6")
|
|
BLOCK = 28 # 대표단면 한 쪽 = 28 행
|
|
E03 = Path("E03_StabSection")
|
|
RESULT2D = E03 / "_derived" / "result2d.json"
|
|
STATION_RESULTS = E03 / "_derived" / "results"
|
|
REPRESENTATIVE = E03 / "representative.json"
|
|
BAND_KEY = "fig/3d_band.png"
|
|
LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N
|
|
CASES = ("dry", "wet", "seismic")
|
|
SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
|
|
METHOD_NAMES = {
|
|
"bishop": "Bishop",
|
|
"janbu": "Janbu 보정",
|
|
"janbu_corrected": "Janbu 보정",
|
|
"ordinary": "Fellenius",
|
|
"spencer": "Spencer",
|
|
}
|
|
REASONS = {
|
|
"fill_10m": "쌓기 높이 10 m 이상",
|
|
"cut_15m": "깎기 높이 15 m 이상",
|
|
"lowest_fs": "최소 안전율 하위",
|
|
"user": "사용자 지정",
|
|
}
|
|
GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"}
|
|
ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17
|
|
A1 = re.compile(r"^([A-Z]+)(\d+)$")
|
|
|
|
|
|
class MissingInput(Exception):
|
|
"""보고서를 만들 해석 결과가 없음(2D 해석 전)."""
|
|
|
|
|
|
# ── 읽기 ─────────────────────────────────────────────────────────────────────
|
|
def _read(path: Path) -> Any:
|
|
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
|
|
|
|
|
|
def stage_dir(project_root: Path) -> Path:
|
|
return Path(project_root) / STAGE
|
|
|
|
|
|
def initial_dir(project_root: Path) -> Path:
|
|
return Path(project_root) / "initial_snapshot" / STAGE
|
|
|
|
|
|
def fig_key(station_key: str) -> str:
|
|
return f"fig/{station_key}.png"
|
|
|
|
|
|
def fig_path(folder: Path, key: str) -> Path:
|
|
"""그림 열쇠 → 파일(윈도우 못 쓰는 글자 `:` 등은 `_`)."""
|
|
name = key.removeprefix("fig/")
|
|
return Path(folder) / "fig" / re.sub(r"[^0-9A-Za-z가-힣._+-]", "_", name)
|
|
|
|
|
|
def form() -> dict[str, Any]:
|
|
return json.loads(FORM_PATH.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _ground(project_root: Path) -> dict[str, Any]:
|
|
return (
|
|
_read(ground_model.ground_path(project_root))
|
|
or _read(ground_model.initial_path(project_root))
|
|
or ground_model.pick(ground_model.load_defaults())
|
|
)
|
|
|
|
|
|
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 analysis_fingerprint(project_root: Path) -> str:
|
|
"""해석 지문 — 2D 요약 지문 · 대표 단면 · 지반 조건 · 3D 저장본 · 양식이 같으면 같음
|
|
(가벼운 읽기만)."""
|
|
project_root = Path(project_root)
|
|
r2 = _read(project_root / RESULT2D)
|
|
folder = run3d.results_dir(project_root)
|
|
body = {
|
|
"2d": [r2.get("input_fingerprint"), r2.get("computed_at")] if r2 else None,
|
|
"rep": _read(project_root / REPRESENTATIVE),
|
|
"ground": _ground(project_root),
|
|
"3d": [
|
|
[p.name, p.stat().st_size, p.stat().st_mtime_ns] for p in sorted(folder.glob("*.json"))
|
|
]
|
|
if folder.is_dir()
|
|
else [],
|
|
"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 gather(project_root: Path) -> dict[str, Any]:
|
|
"""빌더 입력 한 벌 — 2D 결과가 없으면 `MissingInput`."""
|
|
project_root = Path(project_root)
|
|
r2 = _read(project_root / RESULT2D)
|
|
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))
|
|
for item in reps:
|
|
chainage = float(item["summary"]["chainage_m"])
|
|
item["result"] = _station_result(project_root, item["station_key"])
|
|
try:
|
|
item["cross"] = source.cross_section(root, chainage)
|
|
except KeyError:
|
|
item["cross"] = None
|
|
item["layers"] = resolved.get(round(chainage, 3))
|
|
try:
|
|
sections3d = run3d.saved_summaries(project_root)
|
|
except (OSError, ValueError, KeyError):
|
|
sections3d = []
|
|
return {
|
|
"project": db.get("project") or {},
|
|
"route_points": sorted(db.get("route_points") or [], key=lambda p: p["sequence_num"]),
|
|
"stations": source.stations(root),
|
|
"result2d": r2,
|
|
"representatives": reps,
|
|
"ground": ground,
|
|
"sections3d": sorted(sections3d, key=lambda s: s["start_m"]),
|
|
"fingerprint": analysis_fingerprint(project_root),
|
|
}
|
|
|
|
|
|
# ── 칸 쓰기 ──────────────────────────────────────────────────────────────────
|
|
def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None:
|
|
"""값(None = 지움) · 서식(None = 양식 그대로)."""
|
|
cell = dict(sheet["칸"].get(a1) or {})
|
|
cell.pop("식", None)
|
|
if value is None or value == "":
|
|
cell.pop("값", None)
|
|
else:
|
|
cell["값"] = round(value, 6) if isinstance(value, float) else value
|
|
if style is not None:
|
|
cell["서식"] = style
|
|
if cell:
|
|
sheet["칸"][a1] = cell
|
|
else:
|
|
sheet["칸"].pop(a1, None)
|
|
|
|
|
|
def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None:
|
|
for col in cols:
|
|
cell = sheet["칸"].get(f"{col}{src}")
|
|
if cell and dst != src:
|
|
sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {}
|
|
|
|
|
|
def _shift(a1: str, dr: int) -> str:
|
|
m = A1.match(a1)
|
|
return f"{m.group(1)}{int(m.group(2)) + dr}"
|
|
|
|
|
|
def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None:
|
|
if ok is None:
|
|
_set(sheet, a1, None)
|
|
else:
|
|
_set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG)
|
|
|
|
|
|
def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float:
|
|
sf = (ground.get("safety_factors") or {}).get(case) or {}
|
|
if side in ("cut", "fill"):
|
|
return float(sf[side])
|
|
return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽
|
|
|
|
|
|
def _seismic(ground: dict[str, Any]) -> bool:
|
|
return bool((ground.get("seismic") or {}).get("enabled"))
|
|
|
|
|
|
def _labels(stations: list[dict[str, Any]]) -> dict[float, str]:
|
|
return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations}
|
|
|
|
|
|
def _label_at(labels: dict[float, str], chainage: float) -> str:
|
|
if not labels:
|
|
return f"{chainage:.2f}"
|
|
near = min(labels, key=lambda c: abs(c - chainage))
|
|
return labels[near] or f"{chainage:.2f}"
|
|
|
|
|
|
# ── 사용자 몫(처음 한 번) ────────────────────────────────────────────────────
|
|
def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
|
|
"""표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀."""
|
|
project = inputs["project"]
|
|
_set(sheet, "C8", project.get("name"))
|
|
_set(
|
|
sheet,
|
|
"C9",
|
|
" · ".join(str(v) for v in (project.get("region"), project.get("road_type")) if v),
|
|
)
|
|
_set(sheet, "C11", date.today().isoformat())
|
|
|
|
|
|
# ── 프로그램 몫 ──────────────────────────────────────────────────────────────
|
|
WATER_TEXT = {
|
|
"none": "지하수 없음",
|
|
"surface": "지하수위 = 공사 뒤 지표(포화)",
|
|
"half_height": "비탈면 높이 ½ 심도까지 포화",
|
|
}
|
|
|
|
|
|
def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
|
|
"""s3 입력조건."""
|
|
ground = inputs["ground"]
|
|
_set(sheet, "B5", "한계평형해석 — Bishop 간편법(기본) · Janbu 보정 · Fellenius 비교")
|
|
_set(sheet, "B6", "원호(격자 · 진입-탈출) + 비원호(꺾은선) · 측점마다 좌 · 우 비탈 따로")
|
|
_set(
|
|
sheet,
|
|
"B7",
|
|
"Hungr 3D Bishop(+Janbu 보정) · 구 · 타원체 탐색 — 「3D요약」 시트"
|
|
if inputs["sections3d"]
|
|
else "3D 해석 전",
|
|
)
|
|
_set(sheet, "B8", "KDS 11 70 05 비탈면 기준 안전율")
|
|
for r, case in ((12, "dry"), (13, "wet"), (14, "seismic")):
|
|
if case == "seismic" and not _seismic(ground):
|
|
_set(sheet, f"B{r}", "-")
|
|
_set(sheet, f"C{r}", "지진 검토 안 함")
|
|
continue
|
|
sf = ground["safety_factors"][case]
|
|
_set(sheet, f"B{r}", _criterion(ground, case))
|
|
_set(sheet, f"C{r}", f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽")
|
|
water = ground.get("groundwater") or {}
|
|
_set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-"))
|
|
_set(
|
|
sheet,
|
|
"B18",
|
|
f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · "
|
|
f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}",
|
|
)
|
|
seismic = ground.get("seismic") or {}
|
|
_set(
|
|
sheet,
|
|
"B19",
|
|
f"건기 수위 + 수평지진계수 kh = {float(seismic.get('kh') or 0):.2f}"
|
|
if _seismic(ground)
|
|
else "적용 안 함",
|
|
)
|
|
_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 []]
|
|
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} 지층"))
|
|
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):
|
|
r = 26 + i
|
|
_copy_row(sheet, 26, r, "ABCDEFG")
|
|
_set(sheet, f"A{r}", m.get("name") or names.get(m.get("id"), ""))
|
|
_set(sheet, f"B{r}", span)
|
|
for col, field in zip("CDEF", ground_model.MATERIAL_FIELDS):
|
|
_set(sheet, f"{col}{r}", float(m[field]) if field in m else None)
|
|
base = defaults.get(m.get("id"))
|
|
same = base and all(
|
|
float(base[f]) == float(m.get(f, -1)) for f in ground_model.MATERIAL_FIELDS
|
|
)
|
|
_set(
|
|
sheet, f"G{r}", ("문헌 추정치(기본값)" if same else "사용자 입력") if "c" in m else None
|
|
)
|
|
|
|
|
|
def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int:
|
|
"""s4 측점표 — 측점 하나 = 한 줄(6 행부터) · N.G 수."""
|
|
ground = inputs["ground"]
|
|
seismic = _seismic(ground)
|
|
labels = _labels(inputs["stations"])
|
|
rows = sorted(inputs["result2d"]["stations"], key=lambda s: float(s["chainage_m"]))
|
|
ng = 0
|
|
for i, st in enumerate(rows):
|
|
r = 6 + i
|
|
_copy_row(sheet, 6, r, "ABCDEFGHIJ")
|
|
side = st.get("side")
|
|
fs = st.get("fs_by_condition") or {}
|
|
_set(sheet, f"A{r}", _label_at(labels, float(st["chainage_m"])))
|
|
_set(sheet, f"B{r}", float(st["chainage_m"]))
|
|
_set(sheet, f"C{r}", SIDE_NAMES.get(side, side))
|
|
for col, crit, case in (("D", "G", "dry"), ("E", "H", "wet"), ("F", "I", "seismic")):
|
|
use = case != "seismic" or seismic
|
|
value = fs.get(case) if use else None
|
|
_set(sheet, f"{col}{r}", float(value) if value is not None else None)
|
|
_set(sheet, f"{crit}{r}", _criterion(ground, case, side) if use else None)
|
|
verdict = st.get("verdict")
|
|
ok = None if verdict not in ("ok", "ng") else verdict == "ok"
|
|
ng += ok is False
|
|
_verdict(sheet, f"J{r}", ok)
|
|
return ng
|
|
|
|
|
|
def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str):
|
|
"""조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이."""
|
|
best = None
|
|
for case in (result or {}).get("cases") or []:
|
|
if case.get("condition") != condition:
|
|
continue
|
|
if side in ("cut", "fill") and case.get("role") not in (side, None):
|
|
continue
|
|
methods = (case.get("result") or {}).get("methods") or {}
|
|
found = (methods.get(method) or {}).get("min") or next(
|
|
(v["min"] for v in methods.values() if v.get("min")), None
|
|
)
|
|
if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]):
|
|
best = (found, case)
|
|
return best
|
|
|
|
|
|
def _block_copy(sheet: dict[str, Any], template: dict[str, Any], k: int) -> None:
|
|
"""양식 1~28 행 덩이를 k 번째 자리로(칸 · 병합 · 행 높이 · 조건부서식)."""
|
|
dr = BLOCK * k
|
|
for a1, cell in template["칸"].items():
|
|
if int(A1.match(a1).group(2)) <= BLOCK:
|
|
sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
|
|
for m in template.get("병합", []):
|
|
sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
|
|
for r, info in (template.get("행") or {}).items():
|
|
if int(r) <= BLOCK:
|
|
sheet["행"][str(int(r) + dr)] = dict(info)
|
|
for rule in template.get("조건부서식", []):
|
|
moved = copy.deepcopy(rule)
|
|
moved["id"] = f"{rule['id']}_{k}"
|
|
moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]]
|
|
sheet["조건부서식"].append(moved)
|
|
|
|
|
|
CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
|
|
|
|
|
def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
|
|
"""s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)]."""
|
|
template = copy.deepcopy(sheet)
|
|
reps = inputs["representatives"]
|
|
ground = inputs["ground"]
|
|
labels = _labels(inputs["stations"])
|
|
sheet["칸"] = {}
|
|
sheet["병합"], sheet["행"], sheet["조건부서식"], sheet["그림"] = [], {}, [], []
|
|
if not reps:
|
|
_block_copy(sheet, template, 0)
|
|
_set(sheet, "A1", "대표 단면 — 해당 없음")
|
|
sheet["인쇄"]["쪽나눔"] = []
|
|
return []
|
|
jobs: list[tuple[str, str]] = []
|
|
for k, rep in enumerate(reps):
|
|
dr = BLOCK * k
|
|
_block_copy(sheet, template, k)
|
|
st = rep["summary"]
|
|
side = st.get("side")
|
|
method = st.get("method") or "bishop"
|
|
label = _label_at(labels, float(st["chainage_m"]))
|
|
picks = {c: _case_slip(rep.get("result"), c, side, method) for c in CASES}
|
|
dry = picks.get("dry")
|
|
height = float(dry[1].get("height_m") or 0) if dry else 0.0
|
|
mark = CIRCLED[k] if k < len(CIRCLED) else str(k + 1)
|
|
_set(
|
|
sheet,
|
|
f"A{1 + dr}",
|
|
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":
|
|
reason = f"{reason} {LOWEST_N}"
|
|
_set(sheet, f"B{2 + dr}", reason)
|
|
key = fig_key(rep["station_key"])
|
|
sheet["그림"].append(
|
|
{"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"}
|
|
)
|
|
fs = st.get("fs_by_condition") or {}
|
|
slips = []
|
|
for r, case in zip((24, 25, 26), CASES):
|
|
row = r + dr
|
|
if case == "seismic" and not _seismic(ground):
|
|
continue
|
|
value = fs.get(case)
|
|
crit = _criterion(ground, case, side)
|
|
pick = picks.get(case)
|
|
_set(sheet, f"B{row}", METHOD_NAMES.get(method, method))
|
|
_set(sheet, f"C{row}", float(value) if value is not None else None)
|
|
_set(sheet, f"D{row}", crit)
|
|
_verdict(sheet, f"E{row}", None if value is None else float(value) >= crit - 1e-9)
|
|
slip = pick[0] if pick else None
|
|
center = (slip or {}).get("center")
|
|
_set(sheet, f"F{row}", float(slip["radius"]) if slip and slip.get("radius") else None)
|
|
_set(sheet, f"G{row}", float(center[0]) if center else None)
|
|
_set(sheet, f"H{row}", float(center[1]) if center else None)
|
|
if slip and slip.get("points"):
|
|
slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]})
|
|
design = (rep.get("cross") or {}).get("design") or {}
|
|
kind = (dry[0].get("kind") if dry else None) or ""
|
|
layers = rep.get("layers") or {}
|
|
note = [
|
|
{"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""),
|
|
f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}",
|
|
f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "",
|
|
]
|
|
_set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n))
|
|
jobs.append((key, _section_figure(rep, slips, f"{label} — {reason}")))
|
|
sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))]
|
|
return jobs
|
|
|
|
|
|
def _section_figure(rep: dict[str, Any], slips: list[dict[str, Any]], title: str) -> str:
|
|
cross = rep.get("cross") or {}
|
|
design = cross.get("design") or {}
|
|
ground = sorted(
|
|
(float(s["offset_m"]), float(s["z"]))
|
|
for s in cross.get("samples") or []
|
|
if s.get("valid", True) and s.get("z") is not None
|
|
)
|
|
line = sorted(
|
|
(float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or []
|
|
)
|
|
surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground
|
|
thickness = (rep.get("layers") or {}).get("soil_thickness_m")
|
|
rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None
|
|
edges = design.get("road_edges") or {}
|
|
road = None
|
|
if edges.get("left") and edges.get("right"):
|
|
road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"]))
|
|
heights = []
|
|
if road and line:
|
|
for case in (rep.get("result") or {}).get("cases") or []:
|
|
if case.get("condition") != "dry" or not case.get("height_m"):
|
|
continue
|
|
left = case.get("side") == "left"
|
|
edge = road[1] if left else road[0]
|
|
day = line[-1][0] if left else line[0][0]
|
|
z0, z1 = figure._interp(surface, edge), figure._interp(surface, day)
|
|
if z0 is not None and z1 is not None:
|
|
heights.append({"x": day, "z0": z0, "z1": z1})
|
|
return figure.section_svg(
|
|
title=title,
|
|
ground=ground,
|
|
surface=surface,
|
|
rock=rock,
|
|
slips=slips,
|
|
road=road,
|
|
heights=heights,
|
|
wet=any(s["condition"] == "wet" for s in slips),
|
|
)
|
|
|
|
|
|
def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]:
|
|
"""s6 3D요약 — 구간 하나 = 한 줄(20 행부터) · 평면 위험 띠 그림 일감."""
|
|
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"])
|
|
inside = [
|
|
float(s["fs_min"])
|
|
for s in rows2d
|
|
if s.get("fs_min") is not None and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6
|
|
]
|
|
f3 = float(sec["f_min"])
|
|
ok = f3 >= crit - 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))
|
|
_set(sheet, f"D{r}", f3)
|
|
_set(sheet, f"E{r}", min(inside) if inside else None)
|
|
sheet["칸"][f"F{r}"] = {
|
|
"식": f'IF(E{r}>0,D{r}/E{r},"")',
|
|
"서식": sheet["칸"]["F20"]["서식"],
|
|
}
|
|
_set(sheet, f"G{r}", float(sec.get("volume") or 0))
|
|
_verdict(sheet, f"H{r}", ok)
|
|
sections.append({"start_m": start, "end_m": end, "ok": ok})
|
|
if not inputs["sections3d"]:
|
|
_set(sheet, "A20", "3D 해석 전")
|
|
xy = {round(float(s["chainage_m"]), 3): s for s in inputs["stations"]}
|
|
marks = []
|
|
for st in sorted(rows2d, key=lambda s: float(s["chainage_m"])):
|
|
at = xy.get(round(float(st["chainage_m"]), 3))
|
|
if at and at.get("center_x") is not None:
|
|
verdict = st.get("verdict")
|
|
marks.append(
|
|
{
|
|
"x": float(at["center_x"]),
|
|
"y": float(at["center_y"]),
|
|
"ok": None if verdict not in ("ok", "ng") else verdict == "ok",
|
|
"label": at.get("label"),
|
|
}
|
|
)
|
|
line = [
|
|
(float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"]))
|
|
for p in inputs["route_points"]
|
|
]
|
|
svg = figure.band_svg(
|
|
title="평면 위험 띠 — 2D 측점 판정 · 3D 구간", line=line, stations=marks, sections=sections
|
|
)
|
|
return BAND_KEY, svg
|
|
|
|
|
|
def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
|
|
"""양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감."""
|
|
by = {s["id"]: s for s in book["시트"]}
|
|
fill_conditions(by["s3"], inputs)
|
|
fill_stations(by["s4"], inputs)
|
|
jobs = fill_sections(by["s5"], inputs)
|
|
jobs.append(fill_3d(by["s6"], inputs))
|
|
return jobs
|
|
|
|
|
|
def sheet_sign(sheet: dict[str, Any]) -> str:
|
|
"""시트 서명 — 칸 값 · 식 · 서식 · 병합 · 그림 · 행열 크기
|
|
(빈 칸 · 빈 꾸밈은 뺌) · 덮임 알림용."""
|
|
cells = {
|
|
a1: [c.get("값"), c.get("식"), c.get("서식", 0)]
|
|
for a1, c in (sheet.get("칸") or {}).items()
|
|
if c.get("값") is not None or c.get("식") or c.get("서식")
|
|
}
|
|
body = {
|
|
"칸": cells,
|
|
"병합": sorted(sheet.get("병합") or []),
|
|
"그림": sheet.get("그림") or [],
|
|
"행": {k: v for k, v in (sheet.get("행") or {}).items() if v},
|
|
"열": {k: v for k, v in (sheet.get("열") or {}).items() if v},
|
|
}
|
|
text = json.dumps(_numbers(body), ensure_ascii=False, sort_keys=True)
|
|
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _numbers(value: Any) -> Any:
|
|
"""수는 float 로 — 화면(JS)을 거치면 10.0 이 10 으로 옴(같은 값을 다르다고 보지 않게)."""
|
|
if isinstance(value, dict):
|
|
return {k: _numbers(v) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_numbers(v) for v in value]
|
|
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
return round(float(value), 9)
|
|
return value
|