Files
Aislo/E05_StabReport/E05_StabReport_Build.py
T

488 lines
22 KiB
Python

"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3 · 534-5).
입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별
`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약
(있으면) · E07 광역 저장본(있으면) · E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) ·
E06 암반 해석 저장본(있으면 · 없으면 s11 뺌) ·
E01 복사본(측점 · 횡단 · 노선).
양식 = `resources/master_template/report/stab_report.json`
칸 자리 = 같은 폴더 `stab_report_slots.md`.
몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 ·
프로그램(목차 s9 · 입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8 ·
암반검토 s11 · 부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` ·
목차 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔 = `_Print` · 칸 공용 = `_Cells`.
판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임
(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음).
storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`.
계산 자리 = ③ 서버 단독(값 셈 없음 · 결과 옮겨 적기 · 수식은 공용 Node 엔진).
"""
from __future__ import annotations
import hashlib
import json
import re
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_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
from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Reinforce as reinforce
from E05_StabReport import E05_StabReport_Rock as rock
from E05_StabReport import E05_StabReport_Section as section
from E05_StabReport.E05_StabReport_Cells import ( # noqa: F401 — 시험 · 다른 모듈이 build.* 로 씀
A1,
SIDE_NAMES,
ST_NG,
ST_OK,
_copy_row,
_criterion,
_label_at,
_labels,
_seismic,
_set,
_verdict,
fig_key,
mark,
)
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 = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s11", "s10")
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
BAND_KEY = "fig/3d_band.png"
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_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, fp: str) -> tuple[dict[str, Any] | None, list | None]:
"""측점 저장본 — (결과, 해석에 쓴 넓힌 지반선 · 없으면 None)."""
value = (read_derived(Path(project_root), KIND_STATION, fp) or {}).get("value") or {}
return value.get("out"), (value.get("inputs") or {}).get("ground")
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, _ = _latest_summary(project_root, "")
folder = run3d.results_dir(project_root)
body = {
"2d": [r2.get("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 [],
"area": area.stamp(project_root),
"reinforce": reinforce.stamp(project_root),
"rock": rock.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]) -> 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 = _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)
for item in reps:
chainage = float(item["summary"]["chainage_m"])
item["result"], item["ground"] = _station_result(project_root, item["summary"]["fp"])
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"]),
"area": area.read(project_root),
"reinforce": reinforce.read(project_root),
"rock": rock.read(project_root),
"fingerprint": analysis_fingerprint(project_root),
}
# ── 칸 쓰기 ──────────────────────────────────────────────────────────────────
# ── 사용자 몫(처음 한 번) ────────────────────────────────────────────────────
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 _wet_text(water: dict[str, Any], base: dict[str, Any]) -> str:
"""우기 지하수 갈래 글(E02 `wet_mode`) · 기본값이면 사용자 확정 전 표기."""
mode = water.get("wet_mode") or "kds"
if mode == "depth_ratio":
ratio = float(water["depth_ratio"])
return mark(f"지표 아래 깊이 = 비탈 높이 × {ratio:g}", ratio == float(base["depth_ratio"]))
if mode == "ru":
ru = float(water["ru"])
return mark(f"간극수압비 r_u = {ru:g}", ru == float(base["ru"]))
kds = (
f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · "
f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}"
)
if mode == "line":
return f"사용자 수위선 {len(water.get('lines') or [])} 구간 · 구간 밖 = {kds} (사용자 입력)"
same = all(water.get(k) == base.get(k) for k in ("wet_soil", "wet_rock"))
return mark(f"KDS 기본 — {kds}", same)
def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
"""s3 입력조건."""
ground = inputs["ground"]
base = ground_model.load_defaults()
base_sf = base["safety_factors"]
_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]
sf_base = base_sf.get(case) or {}
same = all(float(sf_base.get(k, -1)) == float(sf[k]) for k in ("cut", "fill"))
_set(sheet, f"B{r}", _criterion(ground, case))
text = f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽 · KDS 11 70 05"
_set(sheet, f"C{r}", mark(text, same))
water = ground.get("groundwater") or {}
_set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-"))
_set(sheet, "B18", _wet_text(water, base["groundwater"]))
seismic = ground.get("seismic") or {}
_set(
sheet,
"B19",
f"건기 수위 + 수평지진계수 kh = {float(seismic.get('kh') or 0):.2f}"
if _seismic(ground)
else "적용 안 함",
)
load = float(ground.get("road_surcharge_kpa") or 0)
_set(sheet, "B22", load)
default_load = float(base["road_surcharge_kpa"])
_set(sheet, "D22", mark("옹벽 배면 상재하중 준용", load == default_load))
defaults = {m["id"]: m for m in base["materials"]}
names = {m["id"]: m["name"] 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:
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):
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, "ABCDEFGHIJK")
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)
_set(sheet, f"K{r}", None if ok is None else "-" if ok else "대책 검토")
return ng
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"]
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["min_by_condition"][cond])
for s in rows2d
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"])
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))
_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)
n = len(inputs["result2d"]["stations"])
fill_stations(by["s4"], inputs)
printer.balance_breaks(by["s4"], book, 6, 5 + n)
jobs = section.fill_sections(by["s5"], inputs)
section.fill_slices(by["s10"], inputs)
jobs.append(fill_3d(by["s6"], inputs))
area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"]))
risky = [
r for r in (inputs["area"] or {}).get("stations") or [] if r.get("level") in ("mid", "high")
]
printer.balance_breaks(by["s7"], book, 22, 21 + len(risky))
if inputs.get("reinforce"):
reinforce.fill_reinforce(by["s8"], inputs["reinforce"], _labels(inputs["stations"]))
else: # 보강 검토가 없으면 시트를 뺌
book["시트"] = [s for s in book["시트"] if s["id"] != "s8"]
if inputs.get("rock"):
rock.fill_rock(by["s11"], inputs["rock"], _labels(inputs["stations"]))
else: # 암반 해석이 없으면 시트를 뺌
book["시트"] = [s for s in book["시트"] if s["id"] != "s11"]
printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc`
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