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

This commit is contained in:
2026-10-26 00:55:01 +09:00
14 changed files with 1622 additions and 402 deletions
+33 -1
View File
@@ -9,8 +9,10 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from E02_StabGround.E02_StabGround_Model import load_defaults
from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED
from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS
from E07_StabArea.E07_StabArea_Router import RESULT, load
from E07_StabArea.E07_StabArea_Router import RESULT, AreaRequest, load
LEVEL_NAMES = {"low": "낮음", "mid": "보통", "high": "높음"}
@@ -49,6 +51,35 @@ def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -
sheet["칸"][a1] = cell
UNCONFIRMED_KEYS = ( # (설정 칸, 글) — 기본값 그대로면 사용자 확정 전
("c_root", "뿌리 점착 {c_root:g} kPa"),
("soil_depth_m", "토층 {soil_depth_m:g} m"),
("tr_lo", "T/R {tr_lo:g}~{tr_hi:g} m"),
("level_lo_pct", "측점 문턱 {level_lo_pct:g} % · {level_hi_pct:g} %"),
)
PAIRS = {"tr_lo": "tr_hi", "level_lo_pct": "level_hi_pct"}
def _unconfirmed(s: dict[str, Any]) -> str:
"""기본값 그대로인 설정 · 물성(E02 붕적토 문헌 추정치) — 사용자 확정 전 한 줄."""
fields = AreaRequest.model_fields
same = [
text.format(**s)
for key, text in UNCONFIRMED_KEYS
if all(
k in s and float(s[k]) == float(fields[k].default) for k in (key, PAIRS.get(key, key))
)
]
base = {m["id"]: m for m in load_defaults()["materials"]}.get(
s.get("material_id") or "colluvium"
)
if base and all(
float(s.get(k, -1)) == float(base[k]) for k in ("gamma_t", "gamma_sat", "c", "phi")
):
same.insert(0, f"토층 물성 = {base['name']} 문헌 추정치")
return f"{' · '.join(same)} — {UNCONFIRMED}" if same else "없음(모두 사용자 입력)"
def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None:
"""s7 — 조건 넷 · 등급별 면적 여섯 · 측점 등급별 수 · 보통 · 높음 측점(22 행부터 한 줄씩)."""
if not area:
@@ -68,6 +99,7 @@ def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[f
)
_set(sheet, "B7", scenario_text(s))
_set(sheet, "B8", str(area["computed_at"]).replace("T", " "))
_set(sheet, "B9", _unconfirmed(s))
for k in range(1, 7):
_set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]]))
lo, hi = s["level_lo_pct"], s["level_hi_pct"]
+74 -256
View File
@@ -1,15 +1,16 @@
"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
"""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 뺌) ·
`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약
(있으면) · E07 광역 저장본(있으면) · E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) ·
E01 복사본(측점 · 횡단 · 노선).
양식 = `resources/master_template/report/stab_report.json`
칸 자리 = 같은 폴더 `stab_report_slots.md`.
몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 ·
프로그램(입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8)
= [보고서 갱신] 때 양식에서 통째로 다시 씀.
프로그램(목차 s9 · 입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8 ·
부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` ·
목차 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔 = `_Print` · 칸 공용 = `_Cells`.
판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임
(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음).
@@ -20,7 +21,6 @@ storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`
from __future__ import annotations
import copy
import hashlib
import json
import re
@@ -32,41 +32,37 @@ from common_util.common_util_derived_store import read_derived
from E01_StabRegister import E01_StabRegister_Source as source
from E02_StabGround import E02_StabGround_Model as ground_model
from E03_StabSection import E03_StabSection_View as view2d
from E03_StabSection.E03_StabSection_Engine_Model import final_surface
from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides
from E04_Stab3D import E04_Stab3D_Run as run3d
from E05_StabReport import E05_StabReport_Area as area
from E05_StabReport import E05_StabReport_Figure as figure
from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Reinforce as reinforce
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 = ("s3", "s4", "s5", "s6", "s7", "s8")
BLOCK = 28 # 대표단면 한 쪽 = 28 행
PROGRAM_SHEETS = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s10")
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
BAND_KEY = "fig/3d_band.png"
LOWEST_N = view2d.REPRESENTATIVE_LOWEST
CASES = ("dry", "wet", "seismic")
SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
METHOD_NAMES = {
"bishop": "Bishop",
"janbu": "Janbu 보정",
"janbu_corrected": "Janbu 보정",
"ordinary": "Fellenius",
"spencer": "Spencer",
}
REASONS = {
"fill_height": "쌓기 높이 10 m 이상",
"cut_height": "깎기 높이 15 m 이상",
"lowest": "최소 안전율 하위",
"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):
@@ -86,10 +82,6 @@ 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/")
@@ -108,9 +100,10 @@ def _ground(project_root: Path) -> dict[str, Any]:
)
def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None:
saved = read_derived(Path(project_root), KIND_STATION, fp)
return (saved or {}).get("value", {}).get("out")
def _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]:
@@ -204,7 +197,7 @@ def gather(project_root: Path) -> dict[str, Any]:
reps = _representatives(r2)
for item in reps:
chainage = float(item["summary"]["chainage_m"])
item["result"] = _station_result(project_root, item["summary"]["fp"])
item["result"], item["ground"] = _station_result(project_root, item["summary"]["fp"])
try:
item["cross"] = source.cross_section(root, chainage)
except KeyError:
@@ -229,63 +222,6 @@ def gather(project_root: Path) -> dict[str, Any]:
# ── 칸 쓰기 ──────────────────────────────────────────────────────────────────
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:
"""표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀."""
@@ -307,9 +243,30 @@ WATER_TEXT = {
}
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(
@@ -326,16 +283,14 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
_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))
_set(sheet, f"C{r}", f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽")
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",
f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · "
f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}",
)
_set(sheet, "B18", _wet_text(water, base["groundwater"]))
seismic = ground.get("seismic") or {}
_set(
sheet,
@@ -344,8 +299,11 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
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"]}
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]] = [
@@ -374,7 +332,9 @@ def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
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
sheet,
f"G{r}",
("문헌 추정치 · 사용자 확정 전" if same else "사용자 입력") if "c" in m else None,
)
@@ -387,7 +347,7 @@ def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int:
ng = 0
for i, st in enumerate(rows):
r = 6 + i
_copy_row(sheet, 6, r, "ABCDEFGHIJ")
_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"])))
@@ -402,160 +362,10 @@ def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int:
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 _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":
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"]
@@ -620,14 +430,22 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st
"""양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감."""
by = {s["id"]: s for s in book["시트"]}
fill_conditions(by["s3"], inputs)
n = len(inputs["result2d"]["stations"])
fill_stations(by["s4"], inputs)
jobs = fill_sections(by["s5"], 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"]
printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc`
return jobs
+98
View File
@@ -0,0 +1,98 @@
"""E05 보고서 칸 쓰기 공용 — 이름표 · 서식 번호 · 칸 값 · 판정 · 기준 안전율 · 측점 이름.
(PLAN 534-3 · 534-5) 빌더 · 대표 단면(`_Section`) · 인쇄(`_Print`)가 같이 씀.
"""
from __future__ import annotations
import re
from typing import Any
CASES = ("dry", "wet", "seismic")
SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
METHOD_NAMES = {
"bishop": "Bishop",
"janbu": "Janbu 보정",
"janbu_corrected": "Janbu 보정",
"ordinary": "Fellenius",
"spencer": "Spencer",
}
REASONS = {
"fill_height": "쌓기 높이 10 m 이상",
"cut_height": "깎기 높이 15 m 이상",
"lowest": "최소 안전율 하위",
"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+)$")
def fig_key(station_key: str) -> str:
return f"fig/{station_key}.png"
UNCONFIRMED = "기본값 · 사용자 확정 전"
def mark(text: str, default: bool) -> str:
"""사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 아니면
「(사용자 입력)」."""
return f"{text} ({UNCONFIRMED if default else '사용자 입력'})"
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}"
+166 -82
View File
@@ -1,8 +1,8 @@
"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦.
section_svg(…) → 대표 단면(712 × 533 · 4 : 3) — 원지반 · 공사 뒤 지표 · 암 경계 ·
우기 지하수위 ·
조건별 임계 활동면 · 노폭 · 비탈 높이 치수 · 표고 · 거리 눈금
section_svg(…) → 대표 단면(712 × 640) — 원지반 · 공사 뒤 지표 · 지층(무늬) · 성토(점) ·
우기 지하수위 · 조건별 임계 활동면(선 모양) · 노폭 · 비탈 높이 치수 ·
표고 · 거리 눈금 · 축척 막대 · 안전율 상자 · 지층 범례
band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠
좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset
@@ -16,7 +16,7 @@ import math
from html import escape
from typing import Any
SECTION_W, SECTION_H = 712, 533
SECTION_W, SECTION_H = 712, 640
BAND_W, BAND_H = 684, 320
OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222"
CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"}
@@ -54,6 +54,38 @@ def _interp(line: list[tuple[float, float]], x: float) -> float | None:
return None
def _legend_rows(items: list[tuple[str, str]], width: float, size: int) -> list[list[int]]:
"""범례 항목 [(종류, 글)] → 줄마다 항목 번호(폭을 넘으면 다음 줄)."""
rows: list[list[int]] = [[]]
used = 0.0
for i, (_, label) in enumerate(items):
w = 30 + _text_w(label, size)
if rows[-1] and used + w > width:
rows.append([])
used = 0.0
rows[-1].append(i)
used += w
return rows
def _text_w(text: str, size: int) -> float:
"""글 폭 근사(px) — 한글 · 기호 = 글자 크기 · 그 밖 = 0.6 배."""
return sum(size if ord(ch) > 0x2000 else 0.6 * size for ch in text)
PATTERNS = (
"<pattern id='rockHatch' width='7' height='7' patternUnits='userSpaceOnUse' "
"patternTransform='rotate(45)'><rect width='7' height='7' fill='#E2DACB'/>"
"<line x1='0' y1='0' x2='0' y2='7' stroke='#7A6A55' stroke-width='1'/></pattern>"
"<pattern id='fillDots' width='6' height='6' patternUnits='userSpaceOnUse'>"
"<rect width='6' height='6' fill='#FBF5E8'/><circle cx='3' cy='3' r='0.9' fill='#8D6E63'/>"
"</pattern>"
"<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>"
)
STRATA_FILL = {"soil": "#F3E9D8", "rock": "url(#rockHatch)", "fill": "url(#fillDots)"}
def section_svg(
*,
title: str,
@@ -64,16 +96,20 @@ def section_svg(
road: tuple[float, float] | None,
heights: list[dict[str, Any]],
wet: bool,
strata: list[dict[str, str]] | None = None,
fs_box: list[dict[str, Any]] | None = None,
) -> str:
"""대표 단면 SVG.
"""대표 단면 SVG(흑백 인쇄에도 읽힘 — 임계면 = 선 모양 · 지층 = 무늬).
ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] ·
road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1}] 비탈 높이 치수.
road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1, label?}] 비탈 높이 치수 ·
strata = [{kind: soil | rock | fill, text}] 지층 범례 · fs_box = [{condition, F, required, ok}]
안전율 상자 · title = 빈 글이면 안 씀(보고서 쪽 제목이 있음).
"""
pad_l, pad_r, pad_t, pad_b = 56, 16, 34, 64
pad_l, pad_r, pad_t, pad_b = 58, 18, 30 if title else 22, 88
pts = [*ground, *surface, *(p for s in slips for p in s["points"])]
if not pts:
return _empty(SECTION_W, SECTION_H, title)
return _empty(SECTION_W, SECTION_H, title or "대표 단면")
xs = [p[0] for p in pts]
zs = [p[1] for p in pts]
x_lo, x_hi = min(xs), max(xs)
@@ -94,12 +130,24 @@ def section_svg(
def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]:
return [(sx(x), sz(z)) for x, z in line]
def area(line: list[tuple[float, float]], y: float) -> str:
"""선과 y 사이를 닫은 다각형 점 글."""
body = screen(line)
return " ".join(f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], y), (body[0][0], y)])
bottom, top = sz(z_lo), pad_t
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),
f"<defs>{PATTERNS}",
]
if surface:
out.append(f"<clipPath id='below'><polygon points='{area(surface, bottom)}'/></clipPath>")
if ground:
out.append(f"<clipPath id='above'><polygon points='{area(ground, top)}'/></clipPath>")
out += ["</defs>", f"<rect width='{SECTION_W}' height='{SECTION_H}' fill='#ffffff'/>"]
if title:
out.append(_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
@@ -107,58 +155,57 @@ def section_svg(
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'/>"
"stroke='#E6E6E6' stroke-width='1'/>"
)
out.append(_text(pad_l - 6, y + 4, f"{z:g}", 10, "end", fill="#666666"))
out.append(_text(pad_l - 6, y + 4, f"{z:g}", 11, "end", fill="#444444"))
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
base_y = SECTION_H - pad_b + 15
while x <= x_hi:
out.append(_text(sx(x), base_y, f"{x:g}", 10, fill="#666666"))
out.append(_text(sx(x), base_y, f"{x:g}", 11, fill="#444444"))
x += step_x
out.append(_text(pad_l - 6, pad_t - 8, "EL. (m)", 10, "end", fill="#666666"))
out.append(_text(pad_l - 6, pad_t - 8, "EL.(m)", 10, "end", fill="#444444"))
out.append(
_text(SECTION_W - pad_r, base_y + 14, "offset (m) · 좌 +", 10, "end", fill="#666666")
_text(SECTION_W - pad_r, base_y + 15, "offset (m) · 좌 +", 10, "end", fill="#444444")
)
# 지반 · 지층
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)]
# 축척 막대(가로 = 세로)
bar = _nice_step((x_hi - x_lo) / 5, 1)
bx, by, half = pad_l + 4, base_y + 12, bar * scale / 2
out.append(
f"<rect x='{bx:.1f}' y='{by - 5:.1f}' width='{half:.1f}' height='4' fill='{INK}'/>"
f"<rect x='{bx + half:.1f}' y='{by - 5:.1f}' width='{half:.1f}' height='4' "
f"fill='#ffffff' stroke='{INK}' stroke-width='0.8'/>"
)
out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK))
# 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금)
if surface:
out.append(f"<polygon points='{area(surface, bottom)}' fill='{STRATA_FILL['soil']}'/>")
if ground:
out.append(
f"<polygon points='{area(surface, bottom)}' fill='{STRATA_FILL['fill']}' "
"clip-path='url(#above)'/>"
)
+ "' 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"))
clip = " clip-path='url(#below)'" if surface else ""
out.append(f"<polygon points='{area(rock, bottom)}' fill='{STRATA_FILL['rock']}'{clip}/>")
out.append(_poly(screen(rock), stroke="#6D4C41", stroke_width="1", stroke_dasharray="6 3"))
out.append(_poly(screen(ground), stroke="#6D4C41", stroke_width="1.3", stroke_dasharray="3 2"))
out.append(_poly(screen(surface), stroke=INK, stroke_width="2.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'/>"
f"<polygon points='{a - 6:.1f},{b - 9:.1f} {a + 6:.1f},{b - 9:.1f} "
f"{a:.1f},{b:.1f}' fill='none' stroke='#1565C0' stroke-width='1.4'/>"
)
# 임계 활동면
for slip in slips:
color = CONDITION_COLORS.get(slip["condition"], NG)
dash = SLIP_DASH.get(slip["condition"])
extra = {"stroke_dasharray": dash} if dash else {}
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.2", **extra))
out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.6", **extra))
# 치수 — 노폭 · 비탈 높이
if road:
r_off, l_off = road
@@ -168,9 +215,9 @@ def section_svg(
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)'/>"
"stroke-width='0.9' 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))
out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 11, fill=INK))
for dim in heights:
if abs(dim["z1"] - dim["z0"]) < 0.05:
continue
@@ -178,55 +225,92 @@ def section_svg(
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)'/>"
"stroke-width='0.9' 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, "공사 뒤 지표", "")]
label = dim.get("label") or f"H {abs(dim['z1'] - dim['z0']):.2f} m"
# 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) · 그림 밖으로 나가면 반대쪽
left = a < SECTION_W / 2
if not left and a + 4 + _text_w(label, 11) > SECTION_W - 2:
left = True
if left and a - 4 - _text_w(label, 11) < 2:
left = False
anchor = "end" if left else "start"
out.append(_text(a - 4 if left else a + 4, (y0 + y1) / 2, label, 11, anchor, fill=INK))
if fs_box:
out.append(_fs_box(fs_box, surface, sx, sz, pad_l, pad_t, SECTION_W - pad_r))
# 범례 — 선 · 표시 · 지층 무늬(폭을 넘으면 다음 줄)
items: list[tuple[str, str]] = [("#6D4C41|3 2", "원지반"), (f"{INK}|", "공사 뒤 지표")]
if rock:
legend.append(("#795548", "암 경계", "6 3"))
items.append(("#6D4C41|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,
SLIP_DASH.get(slip["condition"], ""),
)
)
color = CONDITION_COLORS.get(slip["condition"], NG)
label = f"{name} 임계면 Fs {f:.2f}" if f else name
items.append((f"{color}|{SLIP_DASH.get(slip['condition'], '')}", label))
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) # 한글 · 기호 폭
items.append(("▽", "우기 지하수위 = 지표"))
for layer in strata or []:
items.append((f"@{layer['kind']}", layer["text"]))
ly = SECTION_H - 46
for row in _legend_rows(items, SECTION_W - pad_l - pad_r, 11):
lx = float(pad_l)
for i in row:
kind, label = items[i]
if kind.startswith("@"):
out.append(
f"<rect x='{lx:.1f}' y='{ly - 9:.1f}' width='22' height='12' "
f"fill='{STRATA_FILL[kind[1:]]}' stroke='#6D4C41' stroke-width='0.8'/>"
)
elif kind == "▽":
out.append(
f"<polygon points='{lx + 5:.1f},{ly - 9:.1f} {lx + 17:.1f},{ly - 9:.1f} "
f"{lx + 11:.1f},{ly + 1:.1f}' fill='none' stroke='#1565C0' stroke-width='1.4'/>"
)
else:
color, dash = kind.split("|")
dash_attr = f" stroke-dasharray='{dash}'" if dash else ""
out.append(
f"<line x1='{lx:.1f}' y1='{ly - 3:.1f}' x2='{lx + 22:.1f}' y2='{ly - 3:.1f}' "
f"stroke='{color}' stroke-width='2.4'{dash_attr}/>"
)
out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK))
lx += 30 + _text_w(label, 11)
ly += 17
out.append("</svg>")
return "".join(out)
def _fs_box(rows, surface, sx, sz, left: float, top: float, right: float) -> str:
"""안전율 상자 — 하늘 쪽(지표가 낮은 끝) 위 모서리 · 조건 · Fs · 기준 · 판정."""
width, line = 236, 19
height = 24 + line * len(rows)
# surface = offset 오름차순 · 화면 왼끝 = 큰 offset = 끝 점
low_left = bool(surface) and sz(surface[-1][1]) > sz(surface[0][1])
x = left + 6 if low_left else right - width - 6
y = top + 6
out = [
f"<rect x='{x:.1f}' y='{y:.1f}' width='{width}' height='{height}' fill='#ffffff' "
f"stroke='{INK}' stroke-width='1'/>",
f"<line x1='{x:.1f}' y1='{y + 22:.1f}' x2='{x + width:.1f}' y2='{y + 22:.1f}' "
f"stroke='{INK}' stroke-width='0.6'/>",
]
for cx, head, anchor in ((8, "조건", "start"), (78, "Fs", "end"), (132, "기준", "end")):
out.append(_text(x + cx, y + 16, head, 11, anchor, font_weight="bold", fill=INK))
out.append(_text(x + 196, y + 16, "판정", 11, font_weight="bold", fill=INK))
for k, r in enumerate(rows):
yy = y + 22 + line * (k + 1) - 5
name = CONDITION_NAMES.get(r["condition"], r["condition"])
f = "-" if r.get("F") is None else f"{r['F']:.2f}"
verdict = "-" if r.get("ok") is None else "O.K" if r["ok"] else "N.G"
color = INK if r.get("ok") is None else OK if r["ok"] else NG
out.append(_text(x + 8, yy, name, 11, "start", fill=INK))
out.append(_text(x + 78, yy, f, 11, "end", font_weight="bold", fill=INK))
out.append(_text(x + 132, yy, f"≥ {r['required']:.2f}", 11, "end", fill=INK))
out.append(_text(x + 196, yy, verdict, 11, font_weight="bold", fill=color))
return "".join(out)
def band_svg(
*,
title: str,
+229
View File
@@ -0,0 +1,229 @@
"""E05 보고서 인쇄 — 목차 · 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔(요구 347-5 · 351 · PLAN 534-5).
fill_toc(sheet, book, pages) 목차 줄(장 · 내용 · 쪽) — 쪽 없으면 「-」
number_toc(book) 시트별 쪽 수를 재서 목차 쪽 번호를 채움(브라우저 한 번)
report_pdf(book, figures) 표지(쪽 번호 없음) + 본문(머리말 사업명 · 꼬리말 n / N) 한 벌
balance_breaks(sheet, book, first, last)
긴 표 쪽 나눔 — 쪽마다 고르게(마지막 쪽에 한두 줄만 남지 않게)
쪽 번호 = 표지를 뺀 본문 차례(목차 = 1 쪽). 쪽 수 = 시트 하나씩 인쇄 HTML → PDF 쪽 수
(시트 경계 = 늘 새 쪽 · `spreadsheet_print_html.ts`). 쪽 재기 · PDF = 공용 PDF 엔진(헤드리스).
"""
from __future__ import annotations
import io
import math
from html import escape
from typing import Any
from common_util.common_util_print_pdf import merge_pdfs, render_htmls_to_pdfs
from common_util.common_util_spreadsheet import print_htmls
from E05_StabReport.E05_StabReport_Cells import A1, _set
COVER, TOC = "s1", "s9"
TOC_FIRST = 4 # 목차 줄 = 4 행부터(4 행 서식)
TITLES = {
"s2": "개요 및 판정 요약",
"s3": "입력 조건 — 해석 방법 · 기준 안전율 · 지하수 · 하중 · 물성치",
"s4": "측점별 안정성 판정 일람표",
"s5": "대표 단면 해석 결과 — 단면도 · 임계 활동면 · 안전율",
"s6": "3D 해석 요약",
"s7": "광역 자연사면 위험 요약",
"s8": "보강 검토 — 비탈 완화 · 소단 · 벽 외적 안정",
"s10": "대표 단면 절편 계산표",
}
APPENDIX = {"s10"}
REPORT_NAME = "비탈면 안정성 검토 보고서"
FONT = "font-family:'Malgun Gothic','맑은 고딕',sans-serif"
PAPER_MM = {"A4": (210, 297), "A3": (297, 420)}
PX_MM = 96 / 25.4
def _row(a1: str) -> int:
return int(A1.match(a1.split(":")[0]).group(2))
def _visible(book: dict[str, Any]) -> list[dict[str, Any]]:
return [s for s in book["시트"] if not s.get("숨김")]
def _start_pages(book: dict[str, Any], pages: dict[str, int]) -> dict[str, int]:
"""시트 id → 본문 시작 쪽(표지 뺌 · 첫 본문 시트 = 1)."""
out, at = {}, 1
for s in _visible(book):
if s["id"] == COVER:
continue
out[s["id"]] = at
at += pages.get(s["id"], 0)
return out
def fill_toc(sheet: dict[str, Any], book: dict[str, Any], pages: dict[str, int] | None) -> None:
"""목차 — 보이는 본문 시트(표지 · 목차 뺌)마다 한 줄 · 장 번호 · 부록 · 시작 쪽."""
tpl = {c: dict(sheet["칸"].get(f"{c}{TOC_FIRST}") or {}) for c in "ABC"}
for a1 in [a for a in sheet["칸"] if _row(a) >= TOC_FIRST]:
cell = sheet["칸"][a1]
if _row(a1) == TOC_FIRST: # 양식 줄 = 서식만 남김
cell.pop("값", None)
else:
del sheet["칸"][a1]
height = (sheet.get("행") or {}).get(str(TOC_FIRST))
start = _start_pages(book, pages) if pages else {}
chapter = 0
for i, s in enumerate(x for x in _visible(book) if x["id"] not in (COVER, TOC)):
r = TOC_FIRST + i
for c, cell in tpl.items():
sheet["칸"][f"{c}{r}"] = {k: v for k, v in cell.items() if k != "값"}
if height:
sheet["행"][str(r)] = dict(height)
if s["id"] in APPENDIX:
mark = "부록"
else:
chapter += 1
mark = f"{chapter}."
_set(sheet, f"A{r}", mark)
_set(sheet, f"B{r}", TITLES.get(s["id"], s["이름"]))
_set(sheet, f"C{r}", start.get(s["id"], "-"))
def _only(book: dict[str, Any], keep: set[str], hide: bool = False) -> dict[str, Any]:
"""`keep` 시트만 보이는(hide = 참이면 `keep` 만 숨긴) 얕은 사본 · 브라우저 계산값 뺌."""
sheets = [{**s, "숨김": True} if (s["id"] in keep) == hide else s for s in book["시트"]]
return {**{k: v for k, v in book.items() if k != "계산값"}, "시트": sheets}
def _figures(book: dict[str, Any], ids: set[str], figures: dict[str, bytes]) -> dict[str, bytes]:
keys = {f["열쇠"] for s in book["시트"] if s["id"] in ids for f in s.get("그림") or []}
return {k: v for k, v in figures.items() if k in keys}
def sheet_pages(book: dict[str, Any]) -> dict[str, int]:
"""보이는 시트마다 인쇄 쪽 수(그림 없이 — 그림 칸 크기는 행열이 정함)."""
from pypdf import PdfReader
ids = [s["id"] for s in _visible(book)]
htmls = print_htmls([{"book": _only(book, {sid})} for sid in ids])
pdfs = render_htmls_to_pdfs([{"html": h, "page_size": None} for h in htmls])
return {sid: len(PdfReader(io.BytesIO(pdf)).pages) for sid, pdf in zip(ids, pdfs)}
def number_toc(book: dict[str, Any]) -> dict[str, int]:
"""목차 시트가 있으면 쪽 수를 재서 쪽 번호를 채움(책을 고침) · 시트별 쪽 수."""
pages = sheet_pages(book)
toc = next((s for s in book["시트"] if s["id"] == TOC and not s.get("숨김")), None)
if toc is not None:
fill_toc(toc, book, pages)
return pages
def _header(project: str) -> str:
return (
f'<div style="width:100%;margin:0 12mm;font-size:8px;{FONT};color:#555;'
"display:flex;justify-content:space-between;border-bottom:0.5px solid #999;"
f'padding-bottom:2px"><span>{escape(project)}</span><span>{REPORT_NAME}</span></div>'
)
FOOTER = (
f'<div style="width:100%;text-align:center;font-size:8px;{FONT};color:#555">'
'- <span class="pageNumber"></span> / <span class="totalPages"></span> -</div>'
)
def report_pdf(book: dict[str, Any], figures: dict[str, bytes]) -> bytes:
"""보고서 PDF — 목차 쪽 번호 = 실제 쪽 · 표지 = 쪽 번호 · 머리말 없음."""
clean = _only(book, set(), hide=True)
clean["시트"] = [
{**s, "칸": {k: dict(v) for k, v in s["칸"].items()}, "행": dict(s.get("행") or {})}
if s["id"] == TOC
else s
for s in clean["시트"]
] # 목차만 고칠 사본
number_toc(clean)
ids = {s["id"] for s in _visible(clean)}
cover = next((s for s in clean["시트"] if s["id"] == COVER), None)
project = str(((cover or {}).get("칸", {}).get("C8") or {}).get("값") or "")
asks, jobs = [], []
if COVER in ids:
asks.append({"book": _only(clean, {COVER}), "figures": _figures(clean, {COVER}, figures)})
jobs.append({"page_size": None})
if ids - {COVER}:
asks.append({"book": _only(clean, {COVER}, hide=True), "figures": figures})
jobs.append(
{"page_size": None, "header_template": _header(project), "footer_template": FOOTER}
)
htmls = print_htmls(asks)
pdfs = render_htmls_to_pdfs([{**job, "html": h} for job, h in zip(jobs, htmls)])
return merge_pdfs(pdfs) if len(pdfs) > 1 else pdfs[0]
# ── 긴 표 쪽 나눔 ────────────────────────────────────────────────────────────
def _col_px(chars: float) -> int:
"""`spreadsheet_headers.colCharsToPx` 와 같은 꼴."""
return round(min(chars, 255) * 7 + 5)
def _page_fit(sheet: dict[str, Any], book: dict[str, Any]) -> tuple[float, Any]:
"""(쪽 하나 높이 px · 배율 반영) · 행 높이 px 함수 — 인쇄 HTML 과 같은 셈."""
pr = sheet.get("인쇄") or {}
w, h = PAPER_MM[pr.get("용지") or "A4"]
if pr.get("방향") == "landscape":
w, h = h, w
m = {"위": 10, "아래": 10, "왼": 10, "오른": 10, **(pr.get("여백") or {})}
last_col = max(
(sum((ord(ch) - 64) * 26**i for i, ch in enumerate(reversed(A1.match(a).group(1))))
for a in sheet["칸"]),
default=1,
) # fmt: skip
cols = [_col_name(c) for c in range(1, last_col + 1)]
default_w = (
(sheet.get("기본") or {}).get("열폭") or (book.get("기본") or {}).get("열폭") or 8.43
)
table = sum(_col_px((sheet.get("열") or {}).get(c, {}).get("폭") or default_w) for c in cols)
if (pr.get("맞춤") or {}).get("폭"):
zoom = min(1.0, (w - m["왼"] - m["오른"]) * PX_MM / max(table, 1))
else:
zoom = (pr.get("배율") or 100) / 100
default_pt = (book.get("기본") or {}).get("행높이") or 15
def row_px(r: int) -> float:
return ((sheet.get("행") or {}).get(str(r), {}).get("높이") or default_pt) * 4 / 3
return (h - m["위"] - m["아래"]) * PX_MM / zoom, row_px
def _col_name(n: int) -> str:
out = ""
while n:
n, k = divmod(n - 1, 26)
out = chr(65 + k) + out
return out
def balance_breaks(sheet: dict[str, Any], book: dict[str, Any], first: int, last: int) -> None:
"""`first`~`last` 행 표를 쪽마다 고르게 나눔(쪽나눔 = 덩이 끝 행) — 한 쪽이면 그대로.
ponytail: 한 줄 글 행만 맞음(줄바꿈으로 키 큰 행은 셈 밖) · 어긋남 대비 한 줄 덜 담음.
"""
if last < first:
return
page, row_px = _page_fit(sheet, book)
head = (sheet.get("인쇄") or {}).get("머리행")
head_px = sum(row_px(r) for r in range(head[0], head[1] + 1)) if head else 0.0
pre_px = sum(row_px(r) for r in range(1, head[0] if head else first))
rows = list(range(first, last + 1))
cap_first = max(1, math.floor((page - pre_px - head_px) / row_px(first)) - 1)
cap_next = max(1, math.floor((page - head_px) / row_px(first)) - 1)
caps = [cap_first]
while sum(caps) < len(rows): # 쪽 수
caps.append(cap_next)
if len(caps) == 1:
return
breaks, at, left = [], 0, len(rows)
for i, cap in enumerate(caps[:-1]):
take = min(cap, math.ceil(left / (len(caps) - i)))
at += take
left -= take
breaks.append(rows[at - 1])
sheet["인쇄"]["쪽나눔"] = breaks
+3 -4
View File
@@ -29,11 +29,12 @@ 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_spreadsheet import RecalcError, with_server_values
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_Print as printer
from E05_StabReport import E05_StabReport_Store as store
router = APIRouter(prefix="/api/projects", tags=["E05 Stab Report"])
@@ -192,9 +193,7 @@ def _export(root: Path, book: dict[str, Any] | None, kind: str) -> bytes:
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)
return printer.report_pdf(book, figures) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀)
async def _export_response(project_id: UUID, book: dict[str, Any] | None, kind: str) -> Response:
+318
View File
@@ -0,0 +1,318 @@
"""E05 보고서 s5 대표단면 · s10 부록 절편 — 단면 하나 = 한 쪽(PLAN 534-3 · 534-5).
칸 자리 = `resources/master_template/report/stab_report_slots.md` s5 · s10.
그림 = `E05_StabReport_Figure.section_svg`(값 셈 없음 · 해석 저장본 옮겨 그림).
"""
from __future__ import annotations
import copy
import math
from typing import Any
from E03_StabSection import E03_StabSection_View as view2d
from E03_StabSection.E03_StabSection_Engine_Model import final_surface
from E05_StabReport import E05_StabReport_Figure as figure
from E05_StabReport.E05_StabReport_Cells import (
A1,
CASES,
GROUND_NAMES,
METHOD_NAMES,
REASONS,
SIDE_NAMES,
_criterion,
_label_at,
_labels,
_seismic,
_set,
_shift,
_verdict,
fig_key,
)
BLOCK = 28 # 대표단면 한 쪽 = 28 행
LOWEST_N = view2d.REPRESENTATIVE_LOWEST
CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
SLICE_COLS = "ABCDEFGHIJKLM"
SLICE_TPL = 5 # 부록 절편 덩이 꼴 = 양식 1~5 행(제목 · 단위 · 머리 · 줄 · 합계)
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], dr: int, rows: int, tag: str
) -> None:
"""양식 1~rows 행 덩이를 dr 행 아래로(칸 · 병합 · 행 높이 · 조건부서식)."""
for a1, cell in template["칸"].items():
if int(A1.match(a1).group(2)) <= rows:
sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
for m in template.get("병합", []):
if int(A1.match(m.split(":")[0]).group(2)) <= rows:
sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
for r, info in (template.get("행") or {}).items():
if int(r) <= rows:
sheet["행"][str(int(r) + dr)] = dict(info)
for rule in template.get("조건부서식", []):
moved = copy.deepcopy(rule)
moved["id"] = f"{rule['id']}_{tag}"
moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]]
sheet["조건부서식"].append(moved)
def _clear(sheet: dict[str, Any]) -> dict[str, Any]:
"""시트를 비우고 양식 사본을 돌려줌."""
template = copy.deepcopy(sheet)
sheet["칸"] = {}
sheet["병합"], sheet["행"], sheet["조건부서식"] = [], {}, []
if "그림" in sheet:
sheet["그림"] = []
return template
def _mark(k: int) -> str:
return CIRCLED[k] if k < len(CIRCLED) else str(k + 1)
def _picks(rep: dict[str, Any], ground: dict[str, Any]) -> dict[str, Any]:
"""대표 단면 하나 — 조건별 (면, 경우) · 쓰는 조건 · 지배 조건(Fs / 기준 가장 낮음)."""
st = rep["summary"]
side, method = st.get("side"), st.get("method") or "bishop"
used = [c for c in CASES if c != "seismic" or _seismic(ground)]
picks = {c: _case_slip(rep.get("result"), c, side, method) for c in used}
fs = st.get("fs_by_condition") or {}
ratios = [
(float(fs[c]) / _criterion(ground, c, side), c)
for c in used
if fs.get(c) is not None and picks.get(c)
]
governing = min(ratios)[1] if ratios else None
return {"side": side, "method": method, "used": used, "picks": picks, "governing": governing}
def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]:
"""s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)]."""
reps = inputs["representatives"]
ground = inputs["ground"]
labels = _labels(inputs["stations"])
template = _clear(sheet)
if not reps:
_block_copy(sheet, template, 0, BLOCK, "0")
_set(sheet, "A1", "대표 단면 — 해당 없음")
sheet["인쇄"]["쪽나눔"] = []
return []
jobs: list[tuple[str, str]] = []
for k, rep in enumerate(reps):
dr = BLOCK * k
_block_copy(sheet, template, dr, BLOCK, str(k))
st = rep["summary"]
got = _picks(rep, ground)
side, method, picks = got["side"], got["method"], got["picks"]
label = _label_at(labels, float(st["chainage_m"]))
dry = picks.get("dry")
height = float(dry[1].get("height_m") or 0) if dry else 0.0
_set(
sheet,
f"A{1 + dr}",
f"대표 단면 {_mark(k)} — {label} · {SIDE_NAMES.get(side, side or '-')} · "
f"H {height:.1f} m",
)
reason = REASONS.get(rep.get("reason"), rep.get("reason") or "")
if rep.get("reason") == "lowest":
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, box = [], []
for r, case in zip((24, 25, 26), CASES):
row = r + dr
if case not in got["used"]:
_set(sheet, f"B{row}", "검토 안 함")
continue
value = fs.get(case)
crit = _criterion(ground, case, side)
ok = None if value is None else float(value) >= crit - 1e-9
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}", ok)
slip = pick[0] if pick else None
center = (slip or {}).get("center")
circle = bool(slip and slip.get("radius") and center)
_set(sheet, f"F{row}", float(slip["radius"]) if circle else "-" if slip else None)
_set(sheet, f"G{row}", float(center[0]) if circle else "-" if slip else None)
_set(sheet, f"H{row}", float(center[1]) if circle else "-" if slip else None)
if slip and slip.get("points"):
slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]})
box.append({"condition": case, "F": value, "required": crit, "ok": ok})
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 "",
f"부록 {_mark(k)} 절편 계산표" if got["governing"] else "",
]
_set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n))
jobs.append((key, _section_figure(rep, slips, box, ground)))
sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))]
return jobs
def _strata(rep: dict[str, Any], ground: dict[str, Any], fill: bool) -> list[dict[str, str]]:
"""지층 범례 — 성토(공사 뒤 지표가 원지반 위인 곳이 있으면) · 위 지층 · 아래 지층."""
names = {m["id"]: m for m in ground.get("materials") or []}
layers = rep.get("layers") or {}
out = []
picks = [
("fill", (ground.get("label_map") or {}).get("fill") or "fill", fill),
("soil", layers.get("upper"), True),
("rock", layers.get("lower"), bool(layers.get("soil_thickness_m"))),
]
for kind, mid, show in picks:
m = names.get(mid)
if show and m:
text = f"{m['name']} γt {m['gamma_t']:g} · c {m['c']:g} · φ {m['phi']:g}"
out.append({"kind": kind, "text": text})
return out
def _section_figure(
rep: dict[str, Any], slips: list[dict[str, Any]], box: list[dict[str, Any]], soil: dict
) -> str:
cross = rep.get("cross") or {}
design = cross.get("design") or {}
ground = sorted( # 해석에 쓴 넓힌 지반선(임계면이 횡단 표본 밖까지 감) · 없으면 횡단 표본
(float(x), float(z)) for x, z in rep.get("ground") or []
) or 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]
z_edge, z1 = figure._interp(surface, edge), figure._interp(surface, day)
if z_edge is not None and z1 is not None: # 높이 = 엔진 값(비탈 끝에서 길 쪽으로)
h = float(case["height_m"])
z0 = z1 + h if z_edge > z1 else z1 - h
heights.append({"x": day, "z0": z0, "z1": z1, "label": f"H {h:.2f} m"})
filled = any(
(z_g := figure._interp(ground, x)) is not None and z - z_g > 0.05 for x, z in surface
)
return figure.section_svg(
title="",
ground=ground,
surface=surface,
rock=rock,
slips=slips,
road=road,
heights=heights,
wet=any(s["condition"] == "wet" for s in slips),
strata=_strata(rep, soil, filled),
fs_box=box,
)
# ── s10 부록 절편 ────────────────────────────────────────────────────────────
def fill_slices(sheet: dict[str, Any], inputs: dict[str, Any]) -> None:
"""s10 부록 절편 — 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면 절편표 한 쪽."""
ground = inputs["ground"]
labels = _labels(inputs["stations"])
template = _clear(sheet)
data_tpl = {c: dict(template["칸"].get(f"{c}4") or {}) for c in SLICE_COLS}
sum_tpl = {c: dict(template["칸"].get(f"{c}5") or {}) for c in SLICE_COLS}
data_h = (template.get("행") or {}).get("4")
sum_h = (template.get("행") or {}).get("5")
dr, breaks = 0, []
for k, rep in enumerate(inputs["representatives"]):
got = _picks(rep, ground)
cond = got["governing"]
slip = got["picks"][cond][0] if cond else None
rows = (slip or {}).get("slices") or []
if not rows:
continue
_block_copy(sheet, template, dr, 3, str(k))
label = _label_at(labels, float(rep["summary"]["chainage_m"]))
F = float(slip["F"])
_set(
sheet,
f"A{1 + dr}",
f"부록 {_mark(k)} 절편 계산표 — {label} · {SIDE_NAMES.get(got['side'], '-')} · "
f"{CONDITION_NAMES[cond]} · {METHOD_NAMES.get(got['method'], got['method'])} · "
f"Fs {F:.3f}",
)
kind = {"circle": "원호", "polyline": "비원호"}.get(slip.get("kind"), "")
_set(
sheet,
f"A{2 + dr}",
f"대표 단면 {_mark(k)} 의 {CONDITION_NAMES[cond]} 임계 활동면({kind}) · "
f"{template['칸']['A2']['값']}",
)
sums = [0.0, 0.0, 0.0, 0.0]
for i, s in enumerate(rows):
r = 4 + dr + i
for c, cell in data_tpl.items():
sheet["칸"][f"{c}{r}"] = dict(cell)
if data_h:
sheet["행"][str(r)] = dict(data_h)
drive = (s["weight"] + s["surcharge"]) * math.sin(math.radians(s["alpha_deg"]))
resist = s["shear_mobilized"] * F
values = (
i + 1, s["width"], s["height"], s["alpha_deg"], s["weight"], s["surcharge"],
s["pore_pressure"], s["base_length"], s["c"], s["phi_deg"], s["normal_eff"],
drive, resist,
) # fmt: skip
for c, v in zip(SLICE_COLS, values):
_set(sheet, f"{c}{r}", float(v) if c != "A" else v)
for j, v in enumerate((s["weight"], s["surcharge"], drive, resist)):
sums[j] += v
r = 4 + dr + len(rows)
for c, cell in sum_tpl.items():
sheet["칸"][f"{c}{r}"] = dict(cell)
if sum_h:
sheet["행"][str(r)] = dict(sum_h)
_set(sheet, f"A{r}", "합계")
for c, v in zip("EFLM", sums):
_set(sheet, f"{c}{r}", v)
dr = r
breaks.append(r)
if not breaks:
_block_copy(sheet, template, 0, 2, "0")
_set(sheet, "A1", "부록 — 절편 계산표 없음(대표 단면 없음)")
sheet["인쇄"]["쪽나눔"] = breaks
+36 -5
View File
@@ -19,8 +19,10 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from common_util.common_util_print_pdf import PrintError
from common_util.common_util_spreadsheet import with_server_values
from common_util.common_util_svg_png import svgs_to_pngs
from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport.E05_StabReport_Build import (
META,
PROGRAM_SHEETS,
@@ -64,6 +66,15 @@ def _write(project_root: Path, book: dict[str, Any], meta: dict[str, Any]) -> No
atomic_write_json(folder / META, meta)
def _numbered(book: dict[str, Any]) -> dict[str, Any]:
"""목차 쪽 번호를 실제 쪽 수로(브라우저 없으면 「-」 그대로 · PDF 내보내기 때 다시 셈)."""
try:
printer.number_toc(book)
except PrintError:
pass
return book
def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]:
return {
"fingerprint": inputs["fingerprint"],
@@ -79,7 +90,7 @@ def create(project_root: Path) -> dict[str, Any]:
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)
book = _numbered(with_server_values(book))
folder = stage_dir(project_root)
figures = _write_figures(folder, jobs)
meta = _meta(book, inputs, figures)
@@ -94,6 +105,22 @@ def create(project_root: Path) -> dict[str, Any]:
return meta
def _merge_styles(have: list[dict], form_styles: list[dict], sheets: Any) -> list[dict]:
"""새 판 시트의 서식 번호(양식 기준)를 작업본 서식 표 번호로 — 없는 서식은 끝에 붙임
(옛 작업본 · 사용자가 더한 서식과 번호가 엇갈리지 않게)."""
styles = list(have)
remap: dict[int, int] = {}
for i, style in enumerate(form_styles):
if style not in styles:
styles.append(style)
remap[i] = styles.index(style)
for sheet in sheets:
for cell in sheet["칸"].values():
if "서식" in cell:
cell["서식"] = remap.get(cell["서식"], cell["서식"])
return styles
def refresh(project_root: Path) -> dict[str, Any]:
"""[보고서 갱신] — 프로그램 몫 시트 · 그림만 다시. 사용자 몫 · 사용자가 더한 시트는 그대로.
덮인 시트에 사용자 고침이 있었으면 `overwritten`(시트 이름) · 옛 판 `_지난/`."""
@@ -104,6 +131,7 @@ def refresh(project_root: Path) -> dict[str, Any]:
fresh = form()
jobs = build_program(fresh, inputs)
built = {s["id"]: s for s in fresh["시트"] if s["id"] in PROGRAM_SHEETS}
book = {**book, "서식": _merge_styles(book.get("서식") or [{}], fresh["서식"], built.values())}
signs = (meta or {}).get("signs") or {}
overwritten = [
s["이름"]
@@ -123,10 +151,13 @@ def refresh(project_root: Path) -> dict[str, Any]:
for s in book["시트"]
if s["id"] in built or s["id"] not in PROGRAM_SHEETS
]
sheets += [
built[sid] for sid in PROGRAM_SHEETS if sid in built and sid not in have
] # 지운 시트는 끝에 다시
book = with_server_values({**book, "시트": sheets})
order = [s["id"] for s in fresh["시트"]]
for sid in order: # 없는 프로그램 몫(지움 · 새로 생긴 목차 · 부록) = 양식 차례 자리에
if sid in built and sid not in have:
before = {x for x in order[: order.index(sid)]}
at = max((i + 1 for i, x in enumerate(sheets) if x["id"] in before), default=0)
sheets.insert(at, built[sid])
book = _numbered(with_server_values({**book, "시트": sheets}))
figures = _write_figures(folder, jobs)
new_meta = _meta(book, inputs, figures)
_write(project_root, book, new_meta)
+9 -1
View File
@@ -58,6 +58,12 @@ def _pdf_options(job: dict[str, Any]) -> dict[str, Any]:
options.update(
display_header_footer=True, header_template="<span></span>", footer_template=FOOTER
)
if job.get("header_template") or job.get("footer_template"): # 머리말 · 꼬리말 HTML(E05)
options.update(
display_header_footer=True,
header_template=job.get("header_template") or "<span></span>",
footer_template=job.get("footer_template") or "<span></span>",
)
return options
@@ -102,7 +108,9 @@ def render_htmls_to_pdfs(jobs: list[dict[str, Any]], timeout_s: float = TIMEOUT_
"""HTML 여러 벌 → PDF 여러 벌(차례 그대로 · 자식 프로세스 한 번).
job = {html, page_size="A4", landscape=False, margins=None, page_numbers=False,
prepare_js=None, prepare_arg=None} — prepare_js = PDF 직전 page.evaluate(줄임 등).
prepare_js=None, prepare_arg=None, header_template=None, footer_template=None} —
prepare_js = PDF 직전 page.evaluate(줄임 등) · header/footer_template = 쪽마다 머리말 · 꼬리말
HTML(Playwright `pageNumber` · `totalPages` 칸 · 주면 page_numbers 대신).
"""
jobs = [{"page_size": "A4", **job} for job in jobs]
with _lock, tempfile.TemporaryDirectory() as tmp:
+520 -30
View File
@@ -293,6 +293,156 @@
"선": "thin"
}
}
},
{
"크기": 8,
"굵게": true,
"채움": "#DDEBF7",
"가로": "center",
"세로": "center",
"줄바꿈": true,
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
},
{
"크기": 8,
"가로": "center",
"세로": "center",
"형식": "0.00",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
},
{
"크기": 8,
"가로": "center",
"세로": "center",
"형식": "0.0",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
},
{
"크기": 8,
"가로": "center",
"세로": "center",
"형식": "0",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
},
{
"크기": 11,
"가로": "left",
"세로": "center",
"테두리": {
"아래": {
"선": "hair"
}
}
},
{
"크기": 11,
"가로": "right",
"세로": "center",
"형식": "0",
"테두리": {
"아래": {
"선": "hair"
}
}
},
{
"크기": 11,
"굵게": true,
"가로": "left",
"세로": "center",
"테두리": {
"아래": {
"선": "hair"
}
}
},
{
"크기": 8,
"굵게": true,
"채움": "#F2F2F2",
"가로": "center",
"세로": "center",
"형식": "0.00",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
},
{
"크기": 22,
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thick"
},
"아래": {
"선": "thick"
}
}
}
],
"시트": [
@@ -302,7 +452,7 @@
"칸": {
"B4": {
"값": "비탈면 안정성 검토 보고서",
"서식": 1
"서식": 26
},
"C4": {
"서식": 1
@@ -416,6 +566,10 @@
},
"E15": {
"서식": 2
},
"B18": {
"값": "검토 기준 — 건설공사 비탈면 설계기준(KDS 11 70 05) · 한계평형해석(Bishop 간편법)",
"서식": 2
}
},
"병합": [
@@ -427,7 +581,8 @@
"C11:E11",
"C12:E12",
"B14:E14",
"B15:E15"
"B15:E15",
"B18:E18"
],
"열": {
"A": {
@@ -451,7 +606,7 @@
},
"행": {
"4": {
"높이": 60
"높이": 72
},
"5": {
"높이": 24
@@ -476,6 +631,9 @@
},
"12": {
"높이": 30
},
"18": {
"높이": 24
}
},
"보기": {
@@ -496,6 +654,82 @@
}
}
},
{
"id": "s9",
"이름": "목차",
"칸": {
"A1": {
"값": "목 차",
"서식": 1
},
"A3": {
"값": "장",
"서식": 6
},
"B3": {
"값": "내용",
"서식": 6
},
"C3": {
"값": "쪽",
"서식": 6
},
"A4": {
"서식": 24
},
"B4": {
"서식": 22
},
"C4": {
"서식": 23
}
},
"병합": [
"A1:C1"
],
"열": {
"A": {
"폭": 10
},
"B": {
"폭": 64
},
"C": {
"폭": 10
}
},
"행": {
"1": {
"높이": 48
},
"2": {
"높이": 12
},
"3": {
"높이": 24
},
"4": {
"높이": 26
}
},
"보기": {
"눈금선": false
},
"인쇄": {
"용지": "A4",
"방향": "portrait",
"맞춤": {
"폭": 1,
"높이": 0
},
"여백": {
"위": 30,
"아래": 15,
"왼": 20,
"오른": 20
}
}
},
{
"id": "s2",
"이름": "개요",
@@ -1566,6 +1800,9 @@
},
"G26": {
"서식": 5
},
"D22": {
"서식": 5
}
},
"병합": [
@@ -1586,7 +1823,8 @@
"B18:G18",
"B19:G19",
"A21:G21",
"A24:G24"
"A24:G24",
"D22:G22"
],
"열": {
"A": {
@@ -1679,7 +1917,7 @@
"이름": "측점표",
"칸": {
"A1": {
"값": "측점별 안전율",
"값": "측점별 안정성 판정 일람표",
"서식": 1
},
"B1": {
@@ -1710,7 +1948,7 @@
"서식": 1
},
"A2": {
"값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G",
"값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G · N.G 측점 = 대책 검토 필요",
"서식": 2
},
"B2": {
@@ -1841,21 +2079,32 @@
},
"J6": {
"서식": 7
},
"K4": {
"값": "대책 필요",
"서식": 6
},
"K5": {
"서식": 6
},
"K6": {
"서식": 7
}
},
"병합": [
"A1:J1",
"A2:J2",
"A1:K1",
"A2:K2",
"A4:A5",
"B4:B5",
"C4:C5",
"D4:F4",
"G4:I4",
"J4:J5"
"J4:J5",
"K4:K5"
],
"열": {
"A": {
"폭": 16
"폭": 20
},
"B": {
"폭": 13
@@ -1883,6 +2132,9 @@
},
"J": {
"폭": 9
},
"K": {
"폭": 12
}
},
"행": {
@@ -2175,64 +2427,64 @@
"높이": 22
},
"3": {
"높이": 20
"높이": 24
},
"4": {
"높이": 20
"높이": 24
},
"5": {
"높이": 20
"높이": 24
},
"6": {
"높이": 20
"높이": 24
},
"7": {
"높이": 20
"높이": 24
},
"8": {
"높이": 20
"높이": 24
},
"9": {
"높이": 20
"높이": 24
},
"10": {
"높이": 20
"높이": 24
},
"11": {
"높이": 20
"높이": 24
},
"12": {
"높이": 20
"높이": 24
},
"13": {
"높이": 20
"높이": 24
},
"14": {
"높이": 20
"높이": 24
},
"15": {
"높이": 20
"높이": 24
},
"16": {
"높이": 20
"높이": 24
},
"17": {
"높이": 20
"높이": 24
},
"18": {
"높이": 20
"높이": 24
},
"19": {
"높이": 20
"높이": 24
},
"20": {
"높이": 20
"높이": 24
},
"21": {
"높이": 20
"높이": 24
},
"22": {
"높이": 20
"높이": 24
},
"23": {
"높이": 32
@@ -2998,6 +3250,13 @@
},
"G22": {
"서식": 16
},
"A9": {
"값": "기본값",
"서식": 4
},
"B9": {
"서식": 5
}
},
"병합": [
@@ -3008,6 +3267,7 @@
"B6:H6",
"B7:H7",
"B8:H8",
"B9:H9",
"A10:H10",
"A20:H20"
],
@@ -3055,6 +3315,9 @@
},
"21": {
"높이": 32
},
"9": {
"높이": 32
}
},
"보기": {
@@ -3423,6 +3686,233 @@
"오른": 10
}
}
},
{
"id": "s10",
"이름": "부록_절편",
"칸": {
"A1": {
"값": "부록 ① 절편 계산표",
"서식": 3
},
"A2": {
"값": "단위 — 길이 m · 힘 kN/m(폭 1 m) · 압력 · 점착력 kPa · 각 °",
"서식": 2
},
"A3": {
"값": "번호",
"서식": 18
},
"A4": {
"서식": 21
},
"A5": {
"값": "합계",
"서식": 18
},
"B3": {
"값": "폭 b (m)",
"서식": 18
},
"B4": {
"서식": 19
},
"B5": {
"서식": 25
},
"C3": {
"값": "높이 h (m)",
"서식": 18
},
"C4": {
"서식": 19
},
"C5": {
"서식": 25
},
"D3": {
"값": "저면 경사 α (°)",
"서식": 18
},
"D4": {
"서식": 20
},
"D5": {
"서식": 25
},
"E3": {
"값": "무게 W (kN/m)",
"서식": 18
},
"E4": {
"서식": 19
},
"E5": {
"서식": 25
},
"F3": {
"값": "상재 Q (kN/m)",
"서식": 18
},
"F4": {
"서식": 19
},
"F5": {
"서식": 25
},
"G3": {
"값": "간극수압 u (kPa)",
"서식": 18
},
"G4": {
"서식": 19
},
"G5": {
"서식": 25
},
"H3": {
"값": "저면 길이 l (m)",
"서식": 18
},
"H4": {
"서식": 19
},
"H5": {
"서식": 25
},
"I3": {
"값": "점착력 c (kPa)",
"서식": 18
},
"I4": {
"서식": 19
},
"I5": {
"서식": 25
},
"J3": {
"값": "마찰각 φ (°)",
"서식": 18
},
"J4": {
"서식": 20
},
"J5": {
"서식": 25
},
"K3": {
"값": "유효 수직력 N' (kN/m)",
"서식": 18
},
"K4": {
"서식": 19
},
"K5": {
"서식": 25
},
"L3": {
"값": "구동력 (W+Q)·sinα (kN/m)",
"서식": 18
},
"L4": {
"서식": 19
},
"L5": {
"서식": 25
},
"M3": {
"값": "저항력 S (kN/m)",
"서식": 18
},
"M4": {
"서식": 19
},
"M5": {
"서식": 25
}
},
"병합": [
"A1:M1",
"A2:M2"
],
"열": {
"A": {
"폭": 5
},
"B": {
"폭": 7.6
},
"C": {
"폭": 7.6
},
"D": {
"폭": 7.6
},
"E": {
"폭": 7.6
},
"F": {
"폭": 7.6
},
"G": {
"폭": 7.6
},
"H": {
"폭": 7.6
},
"I": {
"폭": 7.6
},
"J": {
"폭": 7.6
},
"K": {
"폭": 7.6
},
"L": {
"폭": 7.6
},
"M": {
"폭": 7.6
}
},
"행": {
"1": {
"높이": 24
},
"2": {
"높이": 16
},
"3": {
"높이": 34
},
"4": {
"높이": 12
},
"5": {
"높이": 14
}
},
"보기": {
"눈금선": false
},
"인쇄": {
"용지": "A4",
"방향": "portrait",
"맞춤": {
"폭": 1,
"높이": 0
},
"여백": {
"위": 15,
"아래": 15,
"왼": 10,
"오른": 10
},
"쪽나눔": [
5
]
}
}
],
"활성": "s1"
@@ -4,8 +4,11 @@
## 공통
- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(입력조건 · 측점표 · 대표단면 · 3D요약) = 갱신 때 통째 다시 씀.
- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00).
- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(목차 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · 보강검토 · 부록_절편) = 갱신 때 통째 다시 씀 · 작업본에 없는 프로그램 몫 시트는 양식 차례 자리에 끼움 · 새 서식은 작업본 서식 표 끝에 붙여 번호를 바꿈.
- 시트 차례: 표지 · 목차 · 개요 · 입력조건 · 측점표 · 대표단면 · 3D요약 · 광역요약 · (보강검토) · 부록_절편.
- PDF: 표지 = 쪽 번호 · 머리말 없음 · 본문 = 머리말(왼 사업명 = 표지 `C8` · 오른 보고서 이름) · 꼬리말 「- n / N -」(목차 = 1 쪽).
- 사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 바꿈 = 「(사용자 입력)」 · 물성 = 「문헌 추정치 · 사용자 확정 전」.
- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00) · 18 head8 · 19 num8(0.00) · 20 num8(0.0) · 21 int8 · 22 목차 내용 · 23 목차 쪽 · 24 목차 장 · 25 sum8(0.00 · 회색) · 26 표지 제목.
- 판정 글(`O.K` · `N.G`) 칸 = 엔진 판정을 값으로 씀 · N.G 칸은 서식 10 · O.K 칸은 서식 11 을 직접 붙임 — 인쇄 HTML · xlsx 는 `조건부서식` 을 안 먹이므로(화면만). 양식의 `조건부서식`(글포함 `N.G` → 빨강)은 사용자가 화면에서 고친 판정 글용.
- 수식이 보는 끝 행 = 20000(측점표 · 3D요약). 측점이 이보다 많으면 개요 수식의 `20000` 을 같이 늘림.
- 빈 값은 칸을 안 적거나 `값` 을 안 둠(빈 글 `""` 금지).
@@ -19,6 +22,16 @@
| C8:E8 · C9:E9 · C10:E10 · C11:E11 · C12:E12 | 사업명 · 노선명 · 발주처 · 작성일 · 작성자 값(병합 · 서식 5) — 왼위 칸 `C8` … `C12` 에 값 |
| B14:E14 | 「문헌 추정치 · 현장 시험 없음」(고정 글 · 건드리지 않음) |
## s9 목차 (프로그램 몫)
| 칸 | 내용 |
| --- | --- |
| `A1:C1` | 제목(고정 글) |
| 3 행 | 머리 — 장 · 내용 · 쪽 |
| 4 행부터 보이는 본문 시트 하나 = 한 줄 | A 장(`1.` … · 부록 = `부록`)(24) · B 내용(22) · C 시작 쪽(23 · 표지 뺀 본문 차례 · 목차 = 1) |
- 쪽 번호 = 시트 하나씩 인쇄 → PDF 쪽 수로 셈(보고서 만들 때 · PDF 내보내기 때 다시) · 브라우저가 없으면 「-」.
## s2 개요 (사용자 몫 · 수식은 양식에 들어 있음)
- 빌더가 쓸 것: 없음(수식이 `측점표` · `입력조건` 을 가리킴). 의견 칸 `A22:F30`(병합 · 서식 15) = 사용자 글.
@@ -34,11 +47,12 @@
| | `C12` `C13` `C14` (C:G 병합) | 비고 글 |
| 지하수 규칙 | `B17` `B18` `B19` (B:G 병합) | 건기 · 우기 · 지진 시 규칙 글 |
| 노면 하중 | `B22` | 수(kPa · 서식 16) |
| | `D22` (D:G 병합) | 비고 — 사용자 확정 전 표기 |
| 지반 물성치 | **26 행부터 한 줄씩** · 머리 25 행(머리 줄 반복) | A 지층 · B 적용 구간(전체 또는 구간 글) · C γt · D γsat · E c · F φ(서식 16) · G 출처(서식 5) · A·B = 서식 7 |
- 지반 물성치가 표의 맨 끝이라 줄 수가 늘어도 다른 칸이 안 밀림. 구간별 덮어쓰기 줄 = 같은 표에 줄 추가(B 열에 구간).
## s4 측점표 (프로그램 몫 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
## s4 측점표 (프로그램 몫 · 판정 일람표 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
- 자료는 **6 행부터 측점 하나 = 한 줄** · 서식은 6 행 서식을 줄마다 그대로.
@@ -50,9 +64,10 @@
| D · E · F | 건기 · 우기 · 지진 안전율 Fs(지진 끔 = 칸 없음) | 8 |
| G · H · I | 건기 · 우기 · 지진 기준 안전율 | 8 |
| J | 판정 `O.K` · `N.G`(엔진 판정 값) | N.G = 10 · O.K = 11 |
| K | 대책 필요(`대책 검토` · O.K = `-`) | 7 |
- 개요 · 3D 수식이 `A6:A20000` · `B` · `D` · `E` · `F` · `J` 열을 읽음 → 이 열 배치 바꾸지 않음.
- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔은 자동(`인쇄.쪽나눔` 안 씀).
- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔 = 빌더가 쪽마다 고르게(`인쇄.쪽나눔` · 마지막 쪽에 한두 줄만 남지 않게).
## s5 대표단면 (프로그램 몫 · 단면 하나 = 28 행 덩이 = 한 쪽)
@@ -62,14 +77,14 @@
| --- | --- | --- |
| 1 | `A1:H1` 병합 | 단면 제목(`대표 단면 ① — 측점 · 구분 · 높이`) |
| 2 | `B2:H2` 병합 | 선정 사유 글 |
| 3~22 | 그림 `A3:H22` | 그림 한 장 — 그림 칸 크기 약 712 × 533 px(가로 : 세로 = 4 : 3) · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
| 3~22 | 그림 `A3:H22` | 그림 한 장 — 행 24 pt · 그림 칸 712 × 640 px · 안전율 상자 · 지층 범례(무늬) · 축척 막대 · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
| 23 | 머리(고정 글) | 조건 · 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · 중심 y |
| 24 · 25 · 26 | A = 건기 · 우기 · 지진(고정 글) | B 해석 방법 글(서식 7) · C Fs(8) · D 기준(8) · E 판정(7→ N.G 10 · O.K 11) · F 반경 · G 중심 x · H 중심 y(13) |
| 27 | `B27:H27` 병합 | 비고 글 |
| 28 | 빈 줄 | 쪽 끝 |
- `인쇄.쪽나눔` = `[28, 56, …, 28 × 단면 수]`(마지막 덩이 끝 쪽 나눔은 있어도 빈 쪽이 안 생김).
- 지진을 안 쓰면 26 행(덩이 안) 칸을 비움.
- 지진을 안 쓰면 26 행(덩이 안) B = 「검토 안 함」. 비원호 면 = F · G · H 「-」. 비고 끝 = 「부록 ⓝ 절편 계산표」.
## s6 3D요약 (프로그램 몫 · 머리 줄 19 행 반복)
@@ -85,6 +100,7 @@
| 칸 | 내용 |
| --- | --- |
| `B5` `B6` `B7` `B8` (각 B:H 병합 · 서식 5) | 노선 띠 · 토층 물성 · 시나리오(표준 SINMAP 습윤지수 · 최악 완전 포화) · 해석 때 글 |
| `B9` (B:H 병합) | 기본값 그대로인 설정 · 물성 — 사용자 확정 전 한 줄 |
| `C12`~`C17` | 등급 1~6(안정 · 보통 안정 · 준안정 · 위험 하한 · 위험 상한 · 불안정) 면적 m²(서식 14) · `D` 비율 = 양식 수식 · `C18` 합계 = 양식 수식 |
| `E11`~`G14` | 측점 등급 표 — 낮음 · 보통 · 높음별 SI<1 셀 비율 기준 글(7) · 측점 수(14) |
| 22 행부터 보통 · 높음 측점 하나 = 한 줄 | A 측점(서식 7) · B 누가거리(17) · C 최소 Fs · D 최소 SI(8) · E 측점 등급 + SI<1 비율 글(7) · F 위험 면적 m²(14) · G 최대 경사 °(16) · 없으면 `A22` 「위험 측점 없음」 |
@@ -100,3 +116,17 @@
| 그 아래 벽 하나 × 경우 하나 = 한 줄 | A 측점(7) · B 벽 이름 · 평상시 / 지진 시 · 토압법 · 배면 수위(5) · C 활동 · E 전도 · G 지지력 Fs(8) · D · F · H 기준(8 · KDS 11 80 05) · I 판정(7 → 10 · 11) · 없으면 「벽 검토 없음」 |
- 6 행 · 10 행 서식을 줄마다 복사 · 저장본 = (측점 · 종류 · 비탈)마다 가장 최근 하나.
## s10 부록_절편 (프로그램 몫 · 대표 단면마다 한 쪽)
양식 1~5 행 = 덩이 꼴(1 제목 · 2 단위 · 3 머리 · 4 절편 줄 서식 · 5 합계 서식). 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면의 절편표.
| 덩이 안 행 | 칸 | 내용 |
| --- | --- | --- |
| 1 | `A:M` 병합 | 「부록 ⓝ 절편 계산표 — 측점 · 구분 · 조건 · 방법 · Fs」 |
| 2 | `A:M` 병합 | 임계면 · 단위 글 |
| 3 | 머리 | 번호 · 폭 b · 높이 h · 저면 경사 α · 무게 W · 상재 Q · 간극수압 u · 저면 길이 l · c · φ · 유효 수직력 N' · 구동력 (W+Q)·sinα · 저항력 S |
| 4 … | 절편 하나 = 한 줄 | 서식 21(번호) · 20(α · φ) · 19(그 밖) |
| 끝 | 합계 | A 「합계」 · E ΣW · F ΣQ · L Σ구동력 · M Σ저항력(서식 25) |
- 저항력 S = 동원 전단 × Fs = c·l + N'·tanφ · 덩이 끝마다 쪽 나눔 · 대표 단면 없음 = `A1` 「부록 — 절편 계산표 없음」.
+87 -11
View File
@@ -1,8 +1,10 @@
"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
"""534-3 · 534-5(빌더 · storage · 길 · 인쇄) — E05 안정성평가 보고서 `E05_StabReport_Build` ·
`_Section` · `_Print` · `_Store` · `_Router`.
가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 ·
3D 저장본 하나 · 복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 ·
갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
갱신 뒤 사용자 몫 불변 · 덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수 ·
목차 쪽 번호 = 실제 · 부록 절편 · 측점표 쪽 나눔 고르게 · 옛 작업본 갱신(목차 자리 · 서식 붙임).
"""
import io
@@ -15,11 +17,11 @@ from openpyxl import load_workbook
from common_util.common_util_derived_store import derived_dir, write_derived
from common_util.common_util_print_pdf import PrintError
from common_util.common_util_spreadsheet import workbook_pdf
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
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_Print as printer
from E05_StabReport import E05_StabReport_Store as store
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -29,6 +31,7 @@ REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_heigh
# 자동 대표(높이 · 하위 5) 중 REPS 만 남기는 사용자 끔
OFF = {"grid:0": False, "grid:5": False, "grid:10": False, "grid:17": False, "grid:24": False}
KIND_SUMMARY, KIND_STATION = "stab2d_summary", "stab2d_station"
SLICES = 24 # 임계면 절편 수
REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
@@ -82,9 +85,24 @@ def _write_summary(root: Path, fp: str, rows: list[dict]) -> None:
write_derived(root, KIND_SUMMARY, fp, value)
def _slice(i: int) -> dict:
return {
"width": 0.5, "height": 1.0 + i / 10, "alpha_deg": 10.0 + i, "weight": 9.0 + i,
"surcharge": 0.0, "pore_pressure": 2.0, "base_length": 0.51, "c": 15.0,
"phi_deg": 30.0, "normal_eff": 8.0 + i, "shear_mobilized": 6.0 + i / 10,
} # fmt: skip
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}
return {
"F": F,
"kind": "circle",
"points": pts,
"center": [0.0, 112.0],
"radius": 12.5,
"slices": [_slice(i) for i in range(SLICES)],
}
def _station_doc(i: int) -> dict:
@@ -208,6 +226,29 @@ def test_처음_만듦_측점수_NG_그림(project):
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)
k = [
c.get("값")
for a1, c in by["측점표"]["칸"].items()
if a1.startswith("K") and int(a1[1:]) >= 6
]
assert k.count("대책 검토") == len(NG_AT) and k.count("-") == N - len(NG_AT)
# 측점표 쪽 나눔 = 고르게(덩이 크기 차 ≤ 1 · 한 줄 꼬리 없음)
cuts = by["측점표"]["인쇄"]["쪽나눔"]
sizes = [b - a for a, b in zip([5, *cuts], [*cuts, 5 + N])]
assert len(sizes) >= 2 and max(sizes) - min(sizes) <= 1
# 부록 절편 — 대표 단면마다 지배 조건 한 덩이 · 저항력 = 동원 전단 × Fs · 덩이 끝 쪽 나눔
s10 = by["부록_절편"]["칸"]
block = 3 + SLICES + 1
assert by["부록_절편"]["인쇄"]["쪽나눔"] == [block * (i + 1) for i in range(len(REPS))]
assert "건기" in s10["A1"]["값"] and s10["A1"]["값"].startswith("부록 ① 절편 계산표 — No.3")
assert s10["A4"]["값"] == 1 and s10["M4"]["값"] == pytest.approx(6.0 * 1.31)
assert s10[f"A{block}"]["값"] == "합계"
assert s10[f"E{block}"]["값"] == pytest.approx(sum(9.0 + i for i in range(SLICES)))
assert book["서식"][s10["B4"]["서식"]]["크기"] == 8
# 목차 — 본문 시트 차례 · 쪽 번호(목차 = 1)
toc = by["목차"]["칸"]
assert [toc[f"B{r}"]["값"] for r in (4, 5)] == [printer.TITLES["s2"], printer.TITLES["s3"]]
assert toc["A10"]["값"] == "부록" # 보강검토 없음 → 본문 6 + 부록 and toc["C4"]["값"] == 2
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]
@@ -356,6 +397,29 @@ def test_갱신_사용자몫_불변_덮임(project):
)
def test_옛_작업본_갱신_목차자리_서식(project):
"""534-5 전 작업본(목차 · 부록 없음 · 서식 18 개) → 갱신 = 목차는 표지 다음 · 부록은 끝 ·
새 서식 붙임(옛 서식 번호 그대로) · 사용자 몫 불변."""
_create(project)
book, meta = store.load(project)
book["시트"] = [s for s in book["시트"] if s["id"] not in ("s9", "s10")]
book["서식"] = book["서식"][:18] + [{"크기": 30}] # 사용자가 더한 서식 = 18 번
_sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 18}
meta["signs"] = {k: v for k, v in meta["signs"].items() if k not in ("s9", "s10")}
store.save(project, book)
(project / "E05_StabReport" / build.META).write_text(json.dumps(meta), encoding="utf-8")
store.refresh(project)
book, _ = store.load(project)
ids = [s["id"] for s in book["시트"]]
assert ids[:3] == ["s1", "s9", "s2"] and ids[-1] == "s10"
by = _sheets(book)
assert by["표지"]["칸"]["C10"] == {"값": "산림청", "서식": 18} and book["서식"][18] == {
"크기": 30
}
head = book["서식"][by["부록_절편"]["칸"]["A3"]["서식"]]
assert head["크기"] == 8 and head["굵게"] and len(book["서식"]) == len(build.form()["서식"]) + 1
def test_3D_없음_2D_없음(project):
shutil.rmtree(project / "E04_Stab3D")
inputs = build.gather(project)
@@ -375,24 +439,36 @@ def test_xlsx_되읽기_pdf_쪽수(project):
figs = store.figures_of(project, book)
assert len(figs) == len(REPS) + 1
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
assert wb.sheetnames == ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약"]
assert wb.sheetnames == [
"표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "부록_절편",
] # fmt: skip
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)
pdf = printer.report_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 + 광역 1
assert len(texts) == 3 + len(stab) + len(REPS) + 1 + 1
assert len(stab) == len(book["시트"][4]["인쇄"]["쪽나눔"]) + 1 # 측점표 = 나눈 덩이 수
# 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 1 + 광역 1 + 부록 3
assert len(texts) == 4 + len(stab) + len(REPS) + 1 + 1 + len(REPS)
body = len(texts) - 1
assert f"/ {body}" not in texts[0] and "검증 임도" not in texts[0].split("보고서")[0]
assert all(f"- {i} / {body} -" in texts[i] and "검증 임도" in texts[i] for i in range(1, body))
# 목차 쪽 번호 = 실제 쪽(본문 n 쪽 = PDF n + 1 번째 쪽)
toc = texts[1]
marks = {"측점별 안정성 판정 일람표": "산정 안전율", printer.TITLES["s5"]: "대표 단면 ①"}
marks[printer.TITLES["s10"]] = "절편 계산표"
for title, inside in marks.items():
line = next(x for x in toc.splitlines() if title in x)
page = int(line.split()[-1])
assert inside in texts[page], (title, page)
def test_단면_그림_SVG():
@@ -430,7 +506,7 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
got = client.get(url).json()
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 7
assert got["editable"] and got["stale"] is False and len(got["book"]["시트"]) == 9
book = got["book"]
_sheets(book)["표지"]["칸"]["C10"] = {"값": "산림청", "서식": 5}
assert client.put(url, json={"rev": "옛판", "book": book}).status_code == 409
@@ -36,7 +36,10 @@ STYLE_KEYS = {
} # fmt: skip
PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"}
A1 = re.compile(r"^([A-Z]{1,3})(\d+)$")
NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토"]
NAMES = [
"표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "보강검토",
"부록_절편",
] # fmt: skip
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -148,7 +151,8 @@ def test_양식_계약():
book = _form()
_contract(book)
assert [s["이름"] for s in book["시트"]] == NAMES
assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"]
ids = ["s1", "s9", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s10"]
assert [s["id"] for s in book["시트"]] == ids
def test_양식_인쇄_쪽나눔_머리행_그림():
@@ -161,6 +165,8 @@ def test_양식_인쇄_쪽나눔_머리행_그림():
figs = {n: len(s.get("그림", [])) for n, s in by.items()}
assert figs == {
"표지": 0,
"목차": 0,
"부록_절편": 0,
"개요": 0,
"입력조건": 0,
"측점표": 0,
@@ -231,8 +237,9 @@ def test_견본_pdf_쪽수_머리행(png):
)
sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t]
assert len(sections) == 2 # 단면마다 한 쪽
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1(양식 빈 판)
assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1
# 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1 + 광역 1 + 보강 1 + 부록 1
# (양식 빈 판)
assert len(pages) == 1 + 1 + 1 + 1 + len(stab) + 2 + 1 + 1 + 1 + 1
assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab)
html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0]
assert html.count("<img ") == 3 # 단면 2 + 3D 띠 1
+2 -2
View File
@@ -10,8 +10,8 @@ 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.",
"표지 · 개요는 사용자 몫 — [보고서 갱신] 때 그대로 둡니다. 목차 · 입력조건 · 측점표 · 대표단면 · 3D · 광역 · 보강 · 부록은 해석 결과로 다시 씁니다.",
"Cover and summary are yours — kept on [Refresh report]. Contents, conditions, stations, sections, 3D, area, reinforcement and appendix sheets are rewritten from results.",
],
E05_Btn_Refresh: ["보고서 갱신", "Refresh report"],
E05_Btn_Xlsx: ["엑셀", "Excel"],