diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py index 810d7c93e..87633ad7a 100644 --- a/E05_StabReport/E05_StabReport_Area.py +++ b/E05_StabReport/E05_StabReport_Area.py @@ -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"] diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py index 9529db131..241c49336 100644 --- a/E05_StabReport/E05_StabReport_Build.py +++ b/E05_StabReport/E05_StabReport_Build.py @@ -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 diff --git a/E05_StabReport/E05_StabReport_Cells.py b/E05_StabReport/E05_StabReport_Cells.py new file mode 100644 index 000000000..fedf4c4ec --- /dev/null +++ b/E05_StabReport/E05_StabReport_Cells.py @@ -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}" diff --git a/E05_StabReport/E05_StabReport_Figure.py b/E05_StabReport/E05_StabReport_Figure.py index 69e4f636c..36816d8d6 100644 --- a/E05_StabReport/E05_StabReport_Figure.py +++ b/E05_StabReport/E05_StabReport_Figure.py @@ -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 = ( + "" + "" + "" + "" + "" + "" +) +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"", - f"", - _text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK), + f"{PATTERNS}", ] + if surface: + out.append(f"") + if ground: + out.append(f"") + out += ["", f""] + 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"" + "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( - "" + f"" + ) + out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK)) + # 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금) + if surface: + out.append(f"") + if ground: + out.append( + f"" ) - + "' fill='#F3E9D8' stroke='none'/>" - ) if rock: - rb = screen(rock) - out.append( - "" - ) - 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"") + 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"" + f"" ) # 임계 활동면 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"" + "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"" + "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, - "" - "", - ) - # 범례 - 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"" - ) - 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"" + ) + elif kind == "▽": + out.append( + f"" + ) + else: + color, dash = kind.split("|") + dash_attr = f" stroke-dasharray='{dash}'" if dash else "" + out.append( + f"" + ) + out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK)) + lx += 30 + _text_w(label, 11) + ly += 17 out.append("") 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"", + f"", + ] + 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, diff --git a/E05_StabReport/E05_StabReport_Print.py b/E05_StabReport/E05_StabReport_Print.py new file mode 100644 index 000000000..2da6e4f76 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Print.py @@ -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'
{escape(project)}{REPORT_NAME}
' + ) + + +FOOTER = ( + f'
' + '- / -
' +) + + +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 diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py index b27f334db..28961e1ec 100644 --- a/E05_StabReport/E05_StabReport_Router.py +++ b/E05_StabReport/E05_StabReport_Router.py @@ -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: diff --git a/E05_StabReport/E05_StabReport_Section.py b/E05_StabReport/E05_StabReport_Section.py new file mode 100644 index 000000000..37ef40bf6 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Section.py @@ -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 diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py index 8579210ed..d234d8959 100644 --- a/E05_StabReport/E05_StabReport_Store.py +++ b/E05_StabReport/E05_StabReport_Store.py @@ -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) diff --git a/common_util/common_util_print_pdf.py b/common_util/common_util_print_pdf.py index af885cb90..0961e2a5f 100644 --- a/common_util/common_util_print_pdf.py +++ b/common_util/common_util_print_pdf.py @@ -58,6 +58,12 @@ def _pdf_options(job: dict[str, Any]) -> dict[str, Any]: options.update( display_header_footer=True, header_template="", 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 "", + footer_template=job.get("footer_template") or "", + ) 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: diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json index 585ab1a29..8ee581788 100644 --- a/resources/master_template/report/stab_report.json +++ b/resources/master_template/report/stab_report.json @@ -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" diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md index b2a94f85f..9a9aadc3c 100644 --- a/resources/master_template/report/stab_report_slots.md +++ b/resources/master_template/report/stab_report_slots.md @@ -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` 「부록 — 절편 계산표 없음」. diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 5e3b36f1d..41da1f402 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -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 diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py index 0a6681409..5e983c265 100644 --- a/resources/tester/test_534_3_stab_report_form.py +++ b/resources/tester/test_534_3_stab_report_form.py @@ -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("