Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-25 20:44:43 +09:00
11 changed files with 2017 additions and 4 deletions
+593
View File
@@ -0,0 +1,593 @@
"""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(body, ensure_ascii=False, sort_keys=True)
return hashlib.sha1(text.encode("utf-8")).hexdigest()
+299
View File
@@ -0,0 +1,299 @@
"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦.
section_svg(…) → 대표 단면(712 × 533 · 4 : 3) — 원지반 · 공사 뒤 지표 · 암 경계 ·
우기 지하수위 ·
조건별 임계 활동면 · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금
band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠
좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset
(B06 횡단도 관례 · `B06_Section_UI_Cross_View.ts`).
값 셈 없음 — 해석 결과(E03 · E04 저장본)를 그리기만.
"""
from __future__ import annotations
import math
from html import escape
from typing import Any
SECTION_W, SECTION_H = 712, 533
BAND_W, BAND_H = 684, 320
OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222"
CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"}
CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
FONT = "font-family='Malgun Gothic, sans-serif'"
def _nice_step(span: float, count: int) -> float:
raw = max(span, 1e-6) / max(count, 1)
base = 10 ** math.floor(math.log10(raw))
return next(m * base for m in (1, 2, 5, 10) if m * base >= raw)
def _poly(points: list[tuple[float, float]], **attrs: str) -> str:
if len(points) < 2:
return ""
d = " ".join(f"{x:.1f},{y:.1f}" for x, y in points)
extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items())
return f"<polyline points='{d}' fill='none' {extra}/>"
def _text(x: float, y: float, text: str, size: int = 11, anchor: str = "middle", **attrs) -> str:
extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items())
return (
f"<text x='{x:.1f}' y='{y:.1f}' font-size='{size}' text-anchor='{anchor}' {FONT} "
f"{extra}>{escape(text)}</text>"
)
def _interp(line: list[tuple[float, float]], x: float) -> float | None:
for (x0, z0), (x1, z1) in zip(line, line[1:]):
if min(x0, x1) - 1e-9 <= x <= max(x0, x1) + 1e-9:
return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0)
return None
def section_svg(
*,
title: str,
ground: list[tuple[float, float]],
surface: list[tuple[float, float]],
rock: list[tuple[float, float]] | None,
slips: list[dict[str, Any]],
road: tuple[float, float] | None,
heights: list[dict[str, Any]],
wet: bool,
) -> str:
"""대표 단면 SVG.
ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] ·
road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1}] 비탈 높이 치수.
"""
pad_l, pad_r, pad_t, pad_b = 56, 16, 34, 64
pts = [*ground, *surface, *(p for s in slips for p in s["points"])]
if not pts:
return _empty(SECTION_W, SECTION_H, title)
xs = [p[0] for p in pts]
zs = [p[1] for p in pts]
x_lo, x_hi = min(xs), max(xs)
z_lo, z_hi = min(zs), max(zs)
z_lo -= 0.08 * (z_hi - z_lo + 1)
z_hi += 0.12 * (z_hi - z_lo + 1)
w, h = SECTION_W - pad_l - pad_r, SECTION_H - pad_t - pad_b
scale = min(w / max(x_hi - x_lo, 1e-6), h / max(z_hi - z_lo, 1e-6)) # 가로 · 세로 같은 축척
cx = pad_l + (w - (x_hi - x_lo) * scale) / 2
cz = pad_t + (h - (z_hi - z_lo) * scale) / 2
def sx(x: float) -> float:
return cx + (x_hi - x) * scale # +offset = 화면 왼쪽
def sz(z: float) -> float:
return cz + (z_hi - z) * scale
def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]:
return [(sx(x), sz(z)) for x, z in line]
out = [
f"<svg xmlns='http://www.w3.org/2000/svg' width='{SECTION_W}' height='{SECTION_H}' "
f"viewBox='0 0 {SECTION_W} {SECTION_H}'>",
f"<rect width='{SECTION_W}' height='{SECTION_H}' fill='#ffffff'/>",
_text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK),
]
# 눈금(표고 · 거리)
step_z = _nice_step(z_hi - z_lo, 6)
z = math.ceil(z_lo / step_z) * step_z
while z <= z_hi:
y = sz(z)
out.append(
f"<line x1='{pad_l}' y1='{y:.1f}' x2='{SECTION_W - pad_r}' y2='{y:.1f}' "
"stroke='#EEEEEE' stroke-width='1'/>"
)
out.append(_text(pad_l - 6, y + 4, f"{z:g}", 10, "end", fill="#666666"))
z += step_z
step_x = _nice_step(x_hi - x_lo, 8)
x = math.ceil(x_lo / step_x) * step_x
base_y = SECTION_H - pad_b + 14
while x <= x_hi:
out.append(_text(sx(x), base_y, f"{x:g}", 10, fill="#666666"))
x += step_x
out.append(_text(pad_l - 6, pad_t - 8, "EL. (m)", 10, "end", fill="#666666"))
out.append(
_text(SECTION_W - pad_r, base_y + 14, "offset (m) · 좌 +", 10, "end", fill="#666666")
)
# 지반 · 지층
if ground:
bottom = sz(z_lo)
body = screen(ground)
out.append(
"<polygon points='"
+ " ".join(
f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], bottom), (body[0][0], bottom)]
)
+ "' fill='#F3E9D8' stroke='none'/>"
)
if rock:
rb = screen(rock)
out.append(
"<polygon points='"
+ " ".join(
f"{a:.1f},{b:.1f}" for a, b in [*rb, (rb[-1][0], sz(z_lo)), (rb[0][0], sz(z_lo))]
)
+ "' fill='#D9D2C5' stroke='none'/>"
)
out.append(_poly(rb, stroke="#795548", stroke_width="1.2", stroke_dasharray="6 3"))
out.append(_poly(screen(ground), stroke="#8D6E63", stroke_width="1.4"))
out.append(_poly(screen(surface), stroke=INK, stroke_width="2"))
if wet and surface: # 우기 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3) — ▽ 표시
for frac in (0.2, 0.8):
px, pz = surface[int(frac * (len(surface) - 1))]
a, b = sx(px), sz(pz) - 4
out.append(
f"<polygon points='{a - 5:.1f},{b - 8:.1f} {a + 5:.1f},{b - 8:.1f} "
f"{a:.1f},{b:.1f}' "
"fill='none' stroke='#1565C0' stroke-width='1.2'/>"
)
# 임계 활동면
for slip in slips:
color = CONDITION_COLORS.get(slip["condition"], NG)
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2"))
# 치수 — 노폭 · 비탈 높이
if road:
r_off, l_off = road
zr, zl = _interp(surface, r_off), _interp(surface, l_off)
if zr is not None and zl is not None:
y = min(sz(zr), sz(zl)) - 18
a, b = sx(l_off), sx(r_off)
out.append(
f"<line x1='{a:.1f}' y1='{y:.1f}' x2='{b:.1f}' y2='{y:.1f}' stroke='{INK}' "
"stroke-width='0.8' marker-start='url(#t)' marker-end='url(#t)'/>"
)
out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 10, fill=INK))
for dim in heights:
if abs(dim["z1"] - dim["z0"]) < 0.05:
continue
a = sx(dim["x"])
y0, y1 = sz(dim["z0"]), sz(dim["z1"])
out.append(
f"<line x1='{a:.1f}' y1='{y0:.1f}' x2='{a:.1f}' y2='{y1:.1f}' stroke='{INK}' "
"stroke-width='0.8' marker-start='url(#t)' marker-end='url(#t)'/>"
)
left = a < SECTION_W / 2 # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침)
label = f"H {abs(dim['z1'] - dim['z0']):.2f} m"
out.append(
_text(
a - 4 if left else a + 4,
(y0 + y1) / 2,
label,
10,
"end" if left else "start",
fill=INK,
)
)
out.insert(
2,
"<defs><marker id='t' viewBox='0 0 4 10' refX='2' refY='5' markerWidth='4' "
"markerHeight='10' orient='auto'><path d='M2 0 L2 10' stroke='#222' stroke-width='1'/>"
"</marker></defs>",
)
# 범례
legend = [("#8D6E63", "원지반", ""), (INK, "공사 뒤 지표", "")]
if rock:
legend.append(("#795548", "암 경계", "6 3"))
for slip in slips:
name = CONDITION_NAMES.get(slip["condition"], slip["condition"])
f = slip.get("F")
legend.append(
(
CONDITION_COLORS.get(slip["condition"], NG),
f"{name} 임계면 Fs {f:.2f}" if f else name,
"",
)
)
if wet:
legend.append(("#1565C0", "▽ 우기 지하수위 = 지표", ""))
lx, ly = pad_l, SECTION_H - 22
for color, label, dash in legend:
dash_attr = f" stroke-dasharray='{dash}'" if dash else ""
out.append(
f"<line x1='{lx}' y1='{ly}' x2='{lx + 18}' y2='{ly}' stroke='{color}' "
f"stroke-width='2'{dash_attr}/>"
)
out.append(_text(lx + 22, ly + 4, label, 10, "start", fill=INK))
lx += 34 + sum(10 if ord(ch) > 0x2000 else 6 for ch in label) # 한글 · 기호 폭
out.append("</svg>")
return "".join(out)
def band_svg(
*,
title: str,
line: list[tuple[float, float, float]],
stations: list[dict[str, Any]],
sections: list[dict[str, Any]],
) -> str:
"""평면 위험 띠 SVG.
line = [(x, y, 누가)] 노선 · stations = [{x, y, ok: bool | None, label}] 2D 판정 ·
sections = [{start_m, end_m, ok: bool}] 3D 구간(노선 위 굵은 띠).
"""
pad = 24
xy = [(p[0], p[1]) for p in line] or [(s["x"], s["y"]) for s in stations]
if not xy:
return _empty(BAND_W, BAND_H, title)
xs = [p[0] for p in xy]
ys = [p[1] for p in xy]
w, h = BAND_W - 2 * pad, BAND_H - 2 * pad - 40
scale = min(w / max(max(xs) - min(xs), 1e-6), h / max(max(ys) - min(ys), 1e-6))
ox = pad + (w - (max(xs) - min(xs)) * scale) / 2
oy = pad + 22 + (h - (max(ys) - min(ys)) * scale) / 2
def sp(x: float, y: float) -> tuple[float, float]:
return ox + (x - min(xs)) * scale, oy + (max(ys) - y) * scale # 북 = 위
out = [
f"<svg xmlns='http://www.w3.org/2000/svg' width='{BAND_W}' height='{BAND_H}' "
f"viewBox='0 0 {BAND_W} {BAND_H}'>",
f"<rect width='{BAND_W}' height='{BAND_H}' fill='#ffffff'/>",
_text(BAND_W / 2, 18, title, 13, font_weight="bold", fill=INK),
]
out.append(_poly([sp(x, y) for x, y in xy], stroke="#BDBDBD", stroke_width="3"))
for sec in sections:
part = [sp(x, y) for x, y, ch in line if sec["start_m"] - 1e-6 <= ch <= sec["end_m"] + 1e-6]
color = OK if sec["ok"] else NG
out.append(_poly(part, stroke=color, stroke_width="12", stroke_opacity="0.35"))
for st in stations:
a, b = sp(st["x"], st["y"])
color = MUTED if st["ok"] is None else OK if st["ok"] else NG
out.append(f"<circle cx='{a:.1f}' cy='{b:.1f}' r='3.2' fill='{color}'/>")
for st in (stations[0], stations[-1]) if stations else ():
a, b = sp(st["x"], st["y"])
out.append(_text(a, b - 7, st["label"] or "", 10, fill=INK))
lx, ly = pad, BAND_H - 14
for color, label, width in (
(OK, "2D O.K", 0),
(NG, "2D N.G", 0),
(OK, "3D 구간 O.K", 12),
(NG, "3D 구간 N.G", 12),
):
if width:
out.append(
f"<line x1='{lx}' y1='{ly - 4}' x2='{lx + 20}' y2='{ly - 4}' stroke='{color}' "
f"stroke-width='{width}' stroke-opacity='0.35'/>"
)
else:
out.append(f"<circle cx='{lx + 10}' cy='{ly - 4}' r='3.2' fill='{color}'/>")
out.append(_text(lx + 26, ly, label, 10, "start", fill=INK))
lx += 120
if not sections:
out.append(_text(BAND_W - pad, ly, "3D 해석 전", 10, "end", fill=MUTED))
out.append("</svg>")
return "".join(out)
def _empty(width: int, height: int, title: str) -> str:
return (
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{height}'>"
f"<rect width='{width}' height='{height}' fill='#ffffff' stroke='#BDBDBD'/>"
+ _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED)
+ "</svg>"
)
+241
View File
@@ -0,0 +1,241 @@
"""E05 안정성평가 보고서 API (요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
- `GET /api/projects/{id}/stab/report` — 작업본(없고 2D 결과가 있으면 처음 만듦 + 초기값) · 판 ·
해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지
- `PUT …/stab/report` — [저장] `{rev, book}` · 판이 다르면 409 · 서버 재계산(`with_server_values`)
- `POST …/stab/report/refresh` — [보고서 갱신] `{rev}` · 프로그램 몫 시트 · 그림만 다시 ·
덮인 시트에 사용자 고침이 있었으면 `overwritten` + 옛 판 `_지난/`
- `POST …/stab/report/reset` — [초기화] 초기값을 작업본에 덮어씀
- `POST …/stab/report/xlsx` · `…/pdf` `{book}` — 화면 지금 책을 서버 재계산 → xlsx · PDF(저장 안 함)
- `GET …/stab/report/xlsx` · `…/pdf` — 작업본으로(보기만 허용된 회사도)
- `GET …/stab/report/fig?key=` — 그림 PNG
계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`
(지정받은 회사 = GET 만 · 그 밖 403).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from urllib.parse import quote
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_print_pdf import PrintError
from common_util.common_util_spreadsheet import RecalcError, with_server_values, workbook_pdf
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
from config.config_db import get_db_pool
from E05_StabReport import E05_StabReport_Build as build
from E05_StabReport import E05_StabReport_Store as store
router = APIRouter(prefix="/api/projects", tags=["E05 Stab Report"])
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
TITLE = "안정성평가_보고서"
_LOCKS: dict[str, asyncio.Lock] = {}
class SaveRequest(BaseModel):
rev: str | None = None
book: dict[str, Any]
class RevRequest(BaseModel):
rev: str | None = None
class BookRequest(BaseModel):
book: dict[str, Any]
def _lock(project_id: UUID) -> asyncio.Lock:
return _LOCKS.setdefault(str(project_id), asyncio.Lock())
async def _project(project_id: UUID) -> tuple[Path, int | None]:
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT storage_path, company_id 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"])), row["company_id"]
def _editable(session: dict[str, Any], company_id: int | None) -> bool:
return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == company_id
def _check_book(book: dict[str, Any]) -> None:
sheets = book.get("시트")
if book.get("종류") != "통합문서" or not isinstance(sheets, list) or not sheets:
raise HTTPException(status_code=400, detail="통합문서 꼴이 아닙니다.")
def _state(root: Path) -> dict[str, Any]:
book, meta = store.load(root)
return {
"book": book,
"rev": store.revision(root),
"meta": meta,
"stale": bool(meta) and meta.get("fingerprint") != build.analysis_fingerprint(root),
"has_initial": (build.initial_dir(root) / build.REPORT).is_file(),
}
def _ensure(root: Path) -> dict[str, Any]:
"""작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭)."""
if store.load(root)[0] is None:
try:
store.create(root)
except build.MissingInput as error:
return {"book": None, "reason": str(error)}
return _state(root)
def _recalc_errors(error: Exception) -> HTTPException:
if isinstance(error, PrintError):
return HTTPException(status_code=503, detail=str(error))
return HTTPException(status_code=422, detail=str(error))
@router.get("/{project_id}/stab/report", response_model=None)
async def get_report(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root, company_id = await _project(project_id)
async with _lock(project_id):
try:
state = await asyncio.to_thread(_ensure, root)
except (RecalcError, PrintError) as error:
raise _recalc_errors(error) from error
return {"status": "success", "editable": _editable(session, company_id), **state}
@router.put("/{project_id}/stab/report", response_model=None)
async def put_report(project_id: UUID, payload: SaveRequest) -> dict[str, Any]:
root, _ = await _project(project_id)
_check_book(payload.book)
async with _lock(project_id):
if store.load(root)[0] is None:
raise HTTPException(status_code=409, detail="보고서가 아직 없습니다.")
if payload.rev != store.revision(root):
raise HTTPException(
status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요."
)
try:
await asyncio.to_thread(build.save, root, payload.book)
except RecalcError as error:
raise _recalc_errors(error) from error
return {"status": "success", **await asyncio.to_thread(_state, root)}
@router.post("/{project_id}/stab/report/refresh", response_model=None)
async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any]:
root, _ = await _project(project_id)
async with _lock(project_id):
current = store.revision(root)
if current is not None and payload.rev != current:
raise HTTPException(
status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요."
)
try:
result = await asyncio.to_thread(build.refresh, root)
except build.MissingInput as error:
raise HTTPException(status_code=409, detail=str(error)) from error
except (RecalcError, PrintError) as error:
raise _recalc_errors(error) from error
return {
"status": "success",
"overwritten": result["overwritten"],
**await asyncio.to_thread(_state, root),
}
@router.post("/{project_id}/stab/report/reset", response_model=None)
async def reset_report(project_id: UUID) -> dict[str, Any]:
root, _ = await _project(project_id)
async with _lock(project_id):
if not await asyncio.to_thread(build.reset, root):
raise HTTPException(status_code=404, detail="초기값이 없습니다.")
return {"status": "success", **await asyncio.to_thread(_state, root)}
def _file(data: bytes, media: str, ext: str, inline: bool = False) -> Response:
kind = "inline" if inline else "attachment"
name = quote(f"{TITLE}.{ext}")
return Response(
content=data,
media_type=media,
headers={"Content-Disposition": f"{kind}; filename*=UTF-8''{name}"},
)
def _export(root: Path, book: dict[str, Any] | None, kind: str) -> bytes:
if book is None:
book = store.load(root)[0]
if book is None:
raise HTTPException(status_code=404, detail="보고서가 아직 없습니다.")
figures = store.figures_of(root, book)
if kind == "xlsx":
return workbook_to_xlsx(with_server_values(book), figures)
clean = dict(book)
clean.pop("계산값", None) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀)
return workbook_pdf(clean, figures)
async def _export_response(project_id: UUID, book: dict[str, Any] | None, kind: str) -> Response:
root, _ = await _project(project_id)
if book is not None:
_check_book(book)
try:
data = await asyncio.to_thread(_export, root, book, kind)
except (RecalcError, PrintError) as error:
raise _recalc_errors(error) from error
if kind == "xlsx":
return _file(data, XLSX, "xlsx")
return _file(data, "application/pdf", "pdf", inline=True)
@router.post("/{project_id}/stab/report/xlsx")
async def post_xlsx(project_id: UUID, payload: BookRequest) -> Response:
return await _export_response(project_id, payload.book, "xlsx")
@router.get("/{project_id}/stab/report/xlsx")
async def get_xlsx(project_id: UUID) -> Response:
return await _export_response(project_id, None, "xlsx")
@router.post("/{project_id}/stab/report/pdf")
async def post_pdf(project_id: UUID, payload: BookRequest) -> Response:
return await _export_response(project_id, payload.book, "pdf")
@router.get("/{project_id}/stab/report/pdf")
async def get_pdf(project_id: UUID) -> Response:
return await _export_response(project_id, None, "pdf")
@router.get("/{project_id}/stab/report/fig")
async def get_figure(project_id: UUID, key: str) -> FileResponse:
root, _ = await _project(project_id)
if not key.startswith("fig/") or ".." in key:
raise HTTPException(status_code=400, detail="그림 열쇠가 틀렸습니다.")
path = build.fig_path(build.stage_dir(root), key)
if not path.is_file():
raise HTTPException(status_code=404, detail="그림이 없습니다.")
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
+159
View File
@@ -0,0 +1,159 @@
"""E05 보고서 storage — 작업본 · 초기값 · 그림 · 옛 판(요구 347-5 · PLAN 534-3).
storage(E 프로젝트 루트):
E05_StabReport/report.json 작업본(Workbook · 서버 재계산 `계산값` 포함)
E05_StabReport/report_meta.json 해석 지문 · 만든 때 · 프로그램 몫 시트 서명(덮임 알림용)
E05_StabReport/fig/<열쇠>.png 그림(열쇠 = `fig/<측점 키>.png` · `fig/3d_band.png`)
E05_StabReport/_지난/<때>.json 갱신이 덮은 옛 판(덮인 시트에 사용자 고침이 있었을 때)
initial_snapshot/E05_StabReport/ 초기값 = 처음 만든 직후 한 벌(report · meta · fig)
시트 채우기 · 그림 SVG = `E05_StabReport_Build.py` · 데이터 3층 = CLAUDE.md 5장.
"""
from __future__ import annotations
import hashlib
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from common_util.common_util_spreadsheet import with_server_values
from common_util.common_util_svg_png import svgs_to_pngs
from E05_StabReport.E05_StabReport_Build import (
META,
PROGRAM_SHEETS,
REPORT,
_read,
build_program,
fig_path,
fill_cover,
form,
gather,
initial_dir,
sheet_sign,
stage_dir,
)
def revision(project_root: Path) -> str | None:
path = stage_dir(project_root) / REPORT
return hashlib.sha1(path.read_bytes()).hexdigest()[:16] if path.is_file() else None
def load(project_root: Path) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
folder = stage_dir(project_root)
return _read(folder / REPORT), _read(folder / META)
def _write_figures(folder: Path, jobs: list[tuple[str, str]]) -> list[str]:
pngs = svgs_to_pngs([svg for _, svg in jobs])
target = Path(folder) / "fig"
if target.is_dir():
shutil.rmtree(target)
target.mkdir(parents=True)
for (key, _), png in zip(jobs, pngs):
fig_path(folder, key).write_bytes(png)
return [key for key, _ in jobs]
def _write(project_root: Path, book: dict[str, Any], meta: dict[str, Any]) -> None:
folder = stage_dir(project_root)
atomic_write_json(folder / REPORT, book)
atomic_write_json(folder / META, meta)
def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]:
return {
"fingerprint": inputs["fingerprint"],
"built_at": datetime.now().isoformat(timespec="seconds"),
"signs": {s["id"]: sheet_sign(s) for s in book["시트"] if s["id"] in PROGRAM_SHEETS},
"figures": figures,
}
def create(project_root: Path) -> dict[str, Any]:
"""처음 만듦 — 양식 전부 · 사용자 몫 처음 채움 · 초기값 한 벌. 돌려줌 = meta."""
inputs = gather(project_root)
book = form()
fill_cover(next(s for s in book["시트"] if s["id"] == "s1"), inputs)
jobs = build_program(book, inputs)
book = with_server_values(book)
folder = stage_dir(project_root)
figures = _write_figures(folder, jobs)
meta = _meta(book, inputs, figures)
_write(project_root, book, meta)
init = initial_dir(project_root)
if init.is_dir():
shutil.rmtree(init)
init.mkdir(parents=True)
for name in (REPORT, META):
shutil.copy2(folder / name, init / name)
shutil.copytree(folder / "fig", init / "fig")
return meta
def refresh(project_root: Path) -> dict[str, Any]:
"""[보고서 갱신] — 프로그램 몫 시트 · 그림만 다시. 사용자 몫 · 사용자가 더한 시트는 그대로.
덮인 시트에 사용자 고침이 있었으면 `overwritten`(시트 이름) · 옛 판 `_지난/`."""
book, meta = load(project_root)
if book is None:
return {"created": True, "overwritten": [], **create(project_root)}
inputs = gather(project_root)
fresh = form()
jobs = build_program(fresh, inputs)
built = {s["id"]: s for s in fresh["시트"] if s["id"] in PROGRAM_SHEETS}
signs = (meta or {}).get("signs") or {}
overwritten = [
s["이름"]
for s in book["시트"]
if s["id"] in built and signs.get(s["id"]) and sheet_sign(s) != signs[s["id"]]
]
folder = stage_dir(project_root)
if overwritten:
old = folder / "_지난"
old.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
shutil.copy2(folder / REPORT, old / f"{stamp}.json")
have = {s["id"] for s in book["시트"]}
sheets = [built.get(s["id"], s) for s in book["시트"]]
sheets += [built[sid] for sid in PROGRAM_SHEETS if sid not in have] # 지운 시트는 끝에 다시
book = with_server_values({**book, "시트": sheets})
figures = _write_figures(folder, jobs)
new_meta = _meta(book, inputs, figures)
_write(project_root, book, new_meta)
return {"created": False, "overwritten": overwritten, **new_meta}
def reset(project_root: Path) -> bool:
"""[초기화] — 초기값을 작업본에 덮어씀(재계산 아님) · 초기값 없으면 False."""
init = initial_dir(project_root)
if not (init / REPORT).is_file():
return False
folder = stage_dir(project_root)
folder.mkdir(parents=True, exist_ok=True)
for name in (REPORT, META):
shutil.copy2(init / name, folder / name)
if (folder / "fig").is_dir():
shutil.rmtree(folder / "fig")
shutil.copytree(init / "fig", folder / "fig")
return True
def save(project_root: Path, book: dict[str, Any]) -> None:
"""[저장] — 브라우저 `계산값` 은 버리고 서버 풀이로 · meta(지문 · 서명)는 그대로."""
clean = with_server_values(book)
atomic_write_json(stage_dir(project_root) / REPORT, clean)
def figures_of(project_root: Path, book: dict[str, Any]) -> dict[str, bytes]:
"""책이 가리키는 그림 열쇠 → PNG(있는 것만) — xlsx · PDF 내보내기용."""
folder = stage_dir(project_root)
out: dict[str, bytes] = {}
for sheet in book.get("시트") or []:
for fig in sheet.get("그림") or []:
path = fig_path(folder, fig["열쇠"])
if path.is_file():
out[fig["열쇠"]] = path.read_bytes()
return out
+300
View File
@@ -0,0 +1,300 @@
/* =============================================================================
* E05_StabReport_UI_Page.ts
* E05 안정성평가 보고서(요구서 345 4-3 · 요구 347-5 · PLAN 534-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/report → 작업본(없고 2D 결과가 있으면 서버가 처음 만듦) · 판 · stale.
* 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
* [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
* [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
* ========================================================================== */
import "./E05_StabReport_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
interface ReportMeta {
fingerprint: string;
built_at: string;
figures: string[];
}
interface ReportResponse {
book: Workbook | null;
rev?: string | null;
meta?: ReportMeta | null;
stale?: boolean;
has_initial?: boolean;
editable?: boolean;
reason?: string;
overwritten?: string[];
}
interface CacheEntry {
rev: string;
book: Workbook;
}
const cacheKey = (projectId: string): string => `e05_report_cache:${projectId}`;
async function failure(response: Response): Promise<Error> {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
return new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
async function request(path: string, init?: RequestInit): Promise<ReportResponse> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
if (!response.ok) throw await failure(response);
return (await response.json()) as ReportResponse;
}
function download(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function renderE05StabReport(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e05-page" });
const staleBar = el("div", { className: "e05-stale" });
staleBar.hidden = true;
const sheetHost = el("div", { className: "e05-sheet" });
main.append(staleBar, sheetHost);
const status = el("p", { className: "e05-status" });
const button = (label: string, variant: "filled" | "ghost" | "danger-ghost", run: () => void) =>
createButton({ label, variant, onClick: run });
const saveButton = button(t("Common_Btn_Save"), "filled", () => void save());
const resetButton = button(t("Common_Btn_Reset"), "danger-ghost", () => void reset());
const refreshButton = button(t("E05_Btn_Refresh"), "ghost", () => void refresh());
const xlsxButton = button(t("E05_Btn_Xlsx"), "ghost", () => void exportFile("xlsx"));
const pdfButton = button(t("E05_Btn_Pdf"), "ghost", () => void exportFile("pdf"));
const previewButton = button(t("E05_Btn_Preview"), "ghost", () => void preview());
const staleButton = button(t("E05_Btn_Refresh"), "filled", () => void refresh());
staleBar.append(el("span", { text: `${t("E05_Stale")} —` }), staleButton);
const all = [saveButton, resetButton, refreshButton, xlsxButton, pdfButton, previewButton];
for (const b of all) b.disabled = true; // 읽어 온 뒤 켬
const exports = el("div", { className: "ui-sidebar-actions" });
exports.append(xlsxButton, pdfButton, previewButton);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(refreshButton, resetButton, saveButton);
const panel = el("div", { className: "ui-sidebar-panel e05-panel" });
panel.append(el("p", { className: "e05-hint", text: t("E05_Note") }), status, exports, actions);
const frame = document.createElement("iframe");
frame.className = "e05-preview__frame";
frame.title = t("E05_Btn_Preview");
const previewBox = el("div", { className: "e05-preview" });
previewBox.hidden = true;
let previewUrl: string | null = null;
const closePreview = (): void => {
previewBox.hidden = true;
frame.removeAttribute("src");
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
};
const head = el("div", { className: "e05-preview__head" });
head.append(button(t("Common_Btn_Close"), "ghost", closePreview));
previewBox.append(head, frame);
main.append(previewBox);
mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel });
if (!projectId)
return void sheetHost.append(el("p", { className: "e05-empty", text: t("E05_NoProject") }));
let sheet: SpreadsheetHandle | null = null;
let state: ReportResponse | null = null;
let editable = false;
let dirty = false;
let busy = false;
const paint = (): void => {
const lines: string[] = [];
if (!editable) lines.push(t("E05_ReadOnly"));
if (busy) lines.push(t("E05_Working"));
if (dirty) lines.push(t("E05_Unsaved"));
if (state?.meta?.built_at)
lines.push(`${t("E05_BuiltAt")} ${state.meta.built_at.replace("T", " ")}`);
status.textContent = lines.join("\n");
const ready = Boolean(state?.book) && !busy;
saveButton.disabled = !ready || !editable || !dirty;
resetButton.disabled = !ready || !editable || !state?.has_initial;
refreshButton.disabled = staleButton.disabled = !ready || !editable;
xlsxButton.disabled = pdfButton.disabled = previewButton.disabled = !ready;
staleBar.hidden = !state?.stale || !editable;
};
const figureSrc = (key: string): string =>
`${API_BASE_URL}/projects/${projectId}/stab/report/fig?key=${encodeURIComponent(key)}` +
`&v=${encodeURIComponent(state?.meta?.built_at ?? "")}`;
/** 서버 응답 받음 — keepCache 면 같은 판 캐시를 살림 */
const adopt = async (body: ReportResponse, keepCache: boolean): Promise<void> => {
state = body;
if (body.editable !== undefined) editable = body.editable;
if (!body.book) {
sheet?.destroy();
sheet = null;
sheetHost.replaceChildren(el("p", { className: "e05-empty", text: body.reason ?? "" }));
dirty = false;
return paint();
}
let book = body.book;
const raw = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null;
const cached = raw ? (JSON.parse(raw) as CacheEntry) : null;
if (cached && cached.rev === body.rev) book = cached.book;
else sessionStorage.removeItem(cacheKey(projectId));
dirty = Boolean(cached && cached.rev === body.rev);
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
sheet?.destroy();
sheetHost.replaceChildren();
sheet = createSpreadsheet(sheetHost, book, {
tabs: true,
readOnly: !editable,
figureSrc,
onChange: (next) => {
dirty = true;
sessionStorage.setItem(
cacheKey(projectId),
JSON.stringify({ rev: state?.rev ?? "", book: next } satisfies CacheEntry),
);
paint();
},
});
paint();
};
const run = async (task: () => Promise<void>, overlay = false): Promise<void> => {
busy = true;
paint();
if (overlay) showLoadingOverlay();
try {
await task();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
if (overlay) hideLoadingOverlay();
busy = false;
paint();
}
};
const putBook = async (): Promise<void> => {
if (!sheet || !state) return;
const body = await request(`/projects/${projectId}/stab/report`, {
method: "PUT",
body: JSON.stringify({ rev: state.rev, book: sheet.getDoc() }),
});
sessionStorage.removeItem(cacheKey(projectId));
state = { ...state, rev: body.rev, meta: body.meta, stale: body.stale };
dirty = false;
};
async function save(): Promise<void> {
if (!editable || !dirty) return;
await run(async () => {
await putBook();
showToast(t("E05_Saved"), "success");
}, true);
}
async function refresh(): Promise<void> {
if (!editable || !state) return;
if (
dirty &&
!(await showConfirmDialog(t("E05_SaveFirst"), t("Common_Btn_Save"), t("Common_Btn_Cancel")))
)
return;
await run(async () => {
if (dirty) await putBook();
const body = await request(`/projects/${projectId}/stab/report/refresh`, {
method: "POST",
body: JSON.stringify({ rev: state?.rev }),
});
await adopt({ ...body, editable }, false);
const over = body.overwritten ?? [];
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
else showToast(t("E05_Refreshed"), "success");
});
}
async function reset(): Promise<void> {
if (!editable) return;
if (
!(await showConfirmDialog(
t("E05_ResetConfirm"),
t("Common_Btn_Reset"),
t("Common_Btn_Cancel"),
))
)
return;
await run(async () => {
const body = await request(`/projects/${projectId}/stab/report/reset`, { method: "POST" });
await adopt({ ...body, editable }, false);
showToast(t("E05_ResetDone"), "success");
}, true);
}
/** 화면 지금 책(고칠 수 있으면) 또는 작업본(보기만) → 서버가 다시 풀어 만든 파일 */
async function fetchFile(kind: "xlsx" | "pdf"): Promise<Blob> {
const url = `${API_BASE_URL}/projects/${projectId}/stab/report/${kind}`;
const init: RequestInit =
editable && sheet
? {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ book: sheet.getDoc() }),
}
: { credentials: "include" };
const response = await fetch(url, init);
if (!response.ok) throw await failure(response);
return response.blob();
}
async function exportFile(kind: "xlsx" | "pdf"): Promise<void> {
await run(async () => download(await fetchFile(kind), `안정성평가_보고서.${kind}`));
}
async function preview(): Promise<void> {
await run(async () => {
const blob = await fetchFile("pdf");
closePreview();
previewUrl = URL.createObjectURL(blob);
frame.src = previewUrl;
previewBox.hidden = false;
});
}
void run(async () => {
try {
await adopt(await request(`/projects/${projectId}/stab/report`), true);
} catch (error) {
sheetHost.replaceChildren(
el("p", {
className: "e05-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
);
}
});
}
@@ -0,0 +1,81 @@
/* =============================================================================
* E05_StabReport_UI_Style.css
* E05 보고서 본문 — 위 띠 · 내장 엑셀 · PDF 미리보기 창. 색은 테마 토큰만(밝음 · 어두움 같이)
* ========================================================================== */
.e05-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.e05-stale {
display: flex;
align-items: center;
gap: var(--spacing-12);
padding: var(--spacing-8) var(--spacing-16);
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
color: var(--color-text);
border-bottom: 1px solid var(--color-border);
}
.e05-stale[hidden] {
display: none;
}
.e05-sheet {
flex: 1;
min-height: 0;
}
.e05-empty,
.e05-status,
.e05-hint {
margin: 0;
white-space: pre-line;
color: var(--color-text-muted);
}
.e05-empty {
padding: var(--spacing-16);
}
.e05-status.is-error {
color: var(--color-danger);
}
.e05-panel .ui-sidebar-actions {
flex-wrap: wrap;
}
.e05-preview {
position: fixed;
inset: 4vh 4vw;
z-index: 50;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
box-shadow: 0 8px 32px rgb(0 0 0 / 35%);
}
.e05-preview[hidden] {
display: none;
}
.e05-preview__head {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
.e05-preview__frame {
flex: 1;
width: 100%;
border: 0;
background: #fff;
}
+3 -4
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* E05_StabReport_Route.ts
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
* 길 선언(PLAN 529-1 · 534-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E05 보고서 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
workflow: true,
project: true,
footerless: true,
load: async () =>
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E05_STAB_REPORT),
load: async () => (await import("../E05_StabReport_UI_Page")).renderE05StabReport,
};
+2
View File
@@ -82,6 +82,7 @@ from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_ro
from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router
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 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
@@ -490,6 +491,7 @@ app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
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(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)
+296
View File
@@ -0,0 +1,296 @@
"""534-3(빌더 · storage) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store`.
가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로
측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · xlsx 되읽기 · PDF 쪽 수.
"""
import io
import json
import shutil
from pathlib import Path
import pytest
from openpyxl import load_workbook
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
from E02_StabGround import E02_StabGround_Model as ground_model
from E05_StabReport import E05_StabReport_Build as build
from E05_StabReport import E05_StabReport_Figure as figure
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")]
def _key(i: int) -> str:
return f"grid:{i}"
def _summary(i: int, bump: float = 0.0) -> dict:
ng = i in NG_AT
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",
}
def _slip(F: float) -> dict:
pts = [[-6.0 + k, 101.0 - 0.3 * (6 - abs(k - 6))] for k in range(13)]
return {"F": F, "kind": "circle", "points": pts, "center": [0.0, 112.0], "radius": 12.5}
def _station_doc(i: int) -> dict:
s = _summary(i)
cases = []
for cond in ("dry", "wet"):
F = s["fs_by_condition"][cond]
for side, role in (("left", "cut"), ("right", "fill")):
cases.append(
{
"condition": cond,
"side": side,
"role": role,
"direction": -1 if side == "left" else 1,
"limits": [-10.0, 10.0],
"height_m": 6.0,
"min_F": {"bishop": F},
"result": {"methods": {"bishop": {"min": _slip(F)}}},
}
)
return {"chainage_m": s["chainage_m"], "label": f"No.{i}", "cases": cases}
def _cross(chainage: float) -> dict:
samples = [{"offset_m": float(x), "z": 100.0 + 0.4 * x, "valid": True} for x in range(-20, 21)]
line = [
{"offset_m": 8.0, "elevation_m": 103.2},
{"offset_m": 2.0, "elevation_m": 100.0},
{"offset_m": -2.0, "elevation_m": 100.0},
{"offset_m": -6.0, "elevation_m": 97.6},
]
return {
"chainage_m": chainage,
"label": f"No.{int(chainage // 20)}",
"samples": samples,
"design": {
"design_line": line,
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
"ground_type": "ripping_rock",
},
}
@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",
)
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"
)
(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",
)
ground = ground_model.pick(ground_model.load_defaults())
ground["stations"] = [
{"chainage_m": 20.0 * i, "ground_type": "ripping_rock", "soil_thickness_m": 1.5}
for i in range(N)
]
ground["zones"] = [
{"from_m": 0, "to_m": 100, "soil": "colluvium", "rock": None, "soil_thickness_m": None}
]
path = ground_model.ground_path(root)
path.parent.mkdir(parents=True)
path.write_text(json.dumps(ground), encoding="utf-8")
r3 = root / "E04_Stab3D" / "results"
r3.mkdir(parents=True)
(r3 / "0.00_200.00.json").write_text(
json.dumps(
{
"fingerprint": "3d-1",
"settings": {},
"summary": {"start_m": 0.0, "end_m": 200.0, "f_min": 1.42, "volume": 830.0},
}
),
encoding="utf-8",
)
stations = [
{"chainage_m": 20.0 * i, "label": f"No.{i}", "center_x": 10.0 * i, "center_y": 3.0 * i}
for i in range(N)
]
db = {
"project": {"name": "검증 임도", "region": "울진군", "road_type": "산불진화임도"},
"route_points": [
{"sequence_num": i, "model_x": 10.0 * i, "model_y": 3.0 * i, "chainage_m": 20.0 * i}
for i in range(N)
],
}
monkeypatch.setattr(build.source, "db_snapshot", lambda _root: db)
monkeypatch.setattr(build.source, "stations", lambda _root: stations)
monkeypatch.setattr(build.source, "cross_section", lambda _root, ch: _cross(ch))
return root
def _sheets(book: dict) -> dict:
return {s["이름"]: s for s in book["시트"]}
def _create(root: Path) -> dict:
try:
return store.create(root)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
def test_처음_만듦_측점수_NG_그림(project):
meta = _create(project)
book, saved = store.load(project)
by = _sheets(book)
rows = [a1 for a1, c in by["측점표"]["칸"].items() if a1.startswith("A") and "값" in c]
rows = [a1 for a1 in rows if int(a1[1:]) >= 6]
assert len(rows) == N # 측점 수 = 줄 수
j = [c for a1, c in by["측점표"]["칸"].items() if a1.startswith("J") and int(a1[1:]) >= 6]
assert sum(c.get("값") == "N.G" and c["서식"] == build.ST_NG for c in j) == len(NG_AT)
assert sum(c.get("값") == "O.K" and c["서식"] == build.ST_OK for c in j) == N - len(NG_AT)
figs = sorted((project / "E05_StabReport" / "fig").glob("*.png"))
assert len(figs) == len(REPS) + 1 and meta["figures"][-1] == build.BAND_KEY
assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS]
assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84]
assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상"
assert by["대표단면"]["칸"]["E24"] == {"값": "N.G", "서식": build.ST_NG} # No.3 건기 1.31
assert by["대표단면"]["칸"]["F24"]["값"] == 12.5
assert by["표지"]["칸"]["C8"]["값"] == "검증 임도"
vals = book["계산값"]["s2"]
assert vals["B5"] == {"수": str(N)} and vals["B15"] == {"수": str(len(NG_AT))}
# 입력조건 — 물성 여섯 + 구간 한 줄 · 3D 한 줄
s3 = by["입력조건"]["칸"]
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 (build.initial_dir(project) / build.REPORT).is_file()
assert saved["fingerprint"] == build.analysis_fingerprint(project)
def test_갱신_사용자몫_불변_덮임(project):
_create(project)
book, _ = store.load(project)
by = _sheets(book)
by["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
by["개요"]["칸"]["A22"] = {"값": "사용자 의견", "서식": 15}
store.save(project, book)
out = store.refresh(project)
assert out["overwritten"] == [] # 프로그램 몫을 안 고침
book, _ = store.load(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")
assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"]
try:
out = store.refresh(project)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
assert out["overwritten"] == ["측점표"]
assert len(list((project / "E05_StabReport" / "_지난").glob("*.json"))) == 1
book, meta = store.load(project)
by = _sheets(book)
assert (
by["표지"]["칸"]["C10"]["값"] == "산림청" and by["개요"]["칸"]["A22"]["값"] == "사용자 의견"
)
assert by["측점표"]["칸"]["A6"]["값"] == "No.0" # 프로그램 몫은 다시 씀
assert book["계산값"]["s2"]["B15"] == {"수": str(len(NG_AT) - 1)} # 수식이 새 값을 따라감
assert meta["fingerprint"] == build.analysis_fingerprint(project)
assert store.reset(project)
book, _ = store.load(project)
assert (
"C10" not in _sheets(book)["표지"]["칸"] or "값" not in _sheets(book)["표지"]["칸"]["C10"]
)
def test_3D_없음_2D_없음(project):
shutil.rmtree(project / "E04_Stab3D")
inputs = build.gather(project)
book = build.form()
build.build_program(book, inputs)
by = _sheets(book)
assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전"
assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전"
(project / build.RESULT2D).unlink()
with pytest.raises(build.MissingInput):
build.gather(project)
def test_xlsx_되읽기_pdf_쪽수(project):
_create(project)
book, _ = store.load(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 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(
"FFC7CE"
)
book.pop("계산값", None)
try:
pdf = workbook_pdf(book, figs)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
from pypdf import PdfReader
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
def test_단면_그림_SVG():
svg = figure.section_svg(
title="No.3",
ground=[(-10.0, 96.0), (10.0, 104.0)],
surface=[(-10.0, 96.0), (-2.0, 100.0), (2.0, 100.0), (10.0, 104.0)],
rock=[(-10.0, 94.5), (10.0, 102.5)],
slips=[{"condition": "dry", "F": 1.3, "points": [[-5.0, 98.0], [0.0, 97.0], [5.0, 102.0]]}],
road=(-2.0, 2.0),
heights=[{"x": 10.0, "z0": 100.0, "z1": 104.0}],
wet=True,
)
assert svg.startswith("<svg") and "건기 임계면 Fs 1.30" in svg and "노폭 4.00 m" in svg
assert "H 4.00 m" in svg and svg.count("<polyline") >= 4
+2
View File
@@ -33,6 +33,7 @@ import { ui_locales_m1 } from "./ui_template_locale_m1";
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";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -73,6 +74,7 @@ export const ui_locales = {
...ui_locales_m2,
...ui_locales_de,
...ui_locales_e,
...ui_locales_e05,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+41
View File
@@ -0,0 +1,41 @@
/* =============================================================================
* ui_template_locale_e05.ts
* 다국어 사전 — E05 안정성평가 보고서(요구 347-5 · PLAN 534-3)
*
* 신규 문구는 해당 섹션 최하단에 추가.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e05 = {
/* --- E05 보고서 --- */
E05_Note: [
"표지 · 개요는 사용자 몫 — [보고서 갱신] 때 그대로 둡니다. 입력조건 · 측점표 · 대표단면 · 3D요약은 해석 결과로 다시 씁니다.",
"Cover and summary are yours — kept on [Refresh report]. Conditions, stations, sections and 3D sheets are rewritten from results.",
],
E05_Btn_Refresh: ["보고서 갱신", "Refresh report"],
E05_Btn_Xlsx: ["엑셀", "Excel"],
E05_Btn_Pdf: ["PDF", "PDF"],
E05_Btn_Preview: ["PDF 미리보기", "PDF preview"],
E05_Stale: ["해석 결과가 바뀜", "Analysis results changed"],
E05_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
E05_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E05_Saved: ["저장했습니다.", "Saved."],
E05_Refreshed: ["보고서를 갱신했습니다.", "Report refreshed."],
E05_Overwritten: [
"고친 내용이 덮인 시트 — 옛 판은 _지난/ 에 보관:",
"Sheets whose edits were overwritten — old copy kept in _지난/:",
],
E05_SaveFirst: [
"저장 안 한 고침이 있습니다. 저장한 뒤 갱신할까요?",
"There are unsaved edits. Save first, then refresh?",
],
E05_ResetConfirm: [
"처음 만든 보고서로 되돌립니다. 고친 내용 · 갱신이 모두 사라집니다.",
"Restore the report as first built. All edits and refreshes are lost.",
],
E05_ResetDone: ["처음 만든 보고서로 되돌렸습니다.", "Restored to the first build."],
E05_Working: ["만드는 중…", "Building…"],
E05_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
E05_BuiltAt: ["보고서 만든 때", "Report built at"],
} as const satisfies Record<string, LocaleEntry>;