"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3). 입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 `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) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 (인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`. 계산 자리 = ③ 서버 단독(값 셈 없음 · 결과 옮겨 적기 · 수식은 공용 Node 엔진). """ from __future__ import annotations import copy import hashlib import json import re from datetime import date from pathlib import Path from typing import Any from 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_Reinforce as reinforce 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 행 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): """보고서를 만들 해석 결과가 없음(2D 해석 전).""" # ── 읽기 ───────────────────────────────────────────────────────────────────── def _read(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None def stage_dir(project_root: Path) -> Path: return Path(project_root) / STAGE def initial_dir(project_root: Path) -> Path: return Path(project_root) / "initial_snapshot" / STAGE def fig_key(station_key: str) -> str: return f"fig/{station_key}.png" def fig_path(folder: Path, key: str) -> Path: """그림 열쇠 → 파일(윈도우 못 쓰는 글자 `:` 등은 `_`).""" name = key.removeprefix("fig/") return Path(folder) / "fig" / re.sub(r"[^0-9A-Za-z가-힣._+-]", "_", name) def form() -> dict[str, Any]: return json.loads(FORM_PATH.read_text(encoding="utf-8")) def _ground(project_root: Path) -> dict[str, Any]: return ( _read(ground_model.ground_path(project_root)) or _read(ground_model.initial_path(project_root)) or ground_model.pick(ground_model.load_defaults()) ) def _station_result(project_root: Path, fp: str) -> dict[str, Any] | None: saved = read_derived(Path(project_root), KIND_STATION, fp) return (saved or {}).get("value", {}).get("out") def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]: """E03 요약 줄 → 보고서 줄 — 비탈 = 기준 대비 가장 낮은 쪽(fs / 기준) · 판정 = 조건 하나라도 N.G 면 ng.""" roles = {s["side"]: s["role"] for s in row.get("sides") or []} cases = row.get("cases") or {} worst = min( ( (c["fs"] / c["required"], side) for sides in cases.values() for side, c in sides.items() if c.get("fs") is not None and c.get("required") ), default=None, ) side = worst[1] if worst else None verdicts = set((row.get("verdict") or {}).values()) values = [v for v in (row.get("min") or {}).values() if v is not None] return { "station_key": row["key"], "chainage_m": row["chainage_m"], "fp": row.get("fp"), "side": roles.get(side), "fs_by_condition": { cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides }, "fs_min": min(values) if values else None, "min_by_condition": row.get("min") or {}, "method": method, "verdict": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, "representative": row.get("representative") or {}, } def _result2d(project_root: Path) -> dict[str, Any] | None: """E03 가장 최근 요약(지금 입력과 달라도 · E03 화면과 같은 판) + 대표 단면 표시.""" summary, _ = _latest_summary(project_root, "") if not summary: return None rows = [dict(r) for r in summary.get("stations") or []] view2d.mark_representative(rows, _overrides(project_root)) method = summary.get("method") or view2d.PRIMARY return {**summary, "stations": [_row2d(r, method) for r in rows]} def analysis_fingerprint(project_root: Path) -> str: """해석 지문 — 2D 요약 지문 · 대표 단면 · 지반 조건 · 3D 저장본 · 양식이 같으면 같음 (가벼운 읽기만).""" project_root = Path(project_root) r2, _ = _latest_summary(project_root, "") folder = run3d.results_dir(project_root) body = { "2d": [r2.get("fingerprint"), r2.get("computed_at")] if r2 else None, "rep": _read(project_root / REPRESENTATIVE), "ground": _ground(project_root), "3d": [ [p.name, p.stat().st_size, p.stat().st_mtime_ns] for p in sorted(folder.glob("*.json")) ] if folder.is_dir() else [], "area": area.stamp(project_root), "reinforce": reinforce.stamp(project_root), "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), } text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha1(text.encode("utf-8")).hexdigest() def _representatives(r2: dict[str, Any]) -> list[dict[str, Any]]: """E03 대표 단면 켬 — 까닭 = 사용자 켬이면 user · 아니면 자동 첫 까닭.""" out = [] for s in r2.get("stations") or []: mark = s["representative"] if mark.get("on"): reason = "user" if mark.get("user") else (mark.get("auto") or ["user"])[0] out.append({"station_key": s["station_key"], "reason": reason, "summary": s}) return out def gather(project_root: Path) -> dict[str, Any]: """빌더 입력 한 벌 — 2D 결과가 없으면 `MissingInput`.""" project_root = Path(project_root) r2 = _result2d(project_root) if not r2 or not r2.get("stations"): raise MissingInput("2D 해석 결과가 없습니다 — E03 에서 [해석] 뒤 만드세요.") root = source.source_dir(project_root) db = source.db_snapshot(root) ground = _ground(project_root) resolved = {round(float(s["chainage_m"]), 3): s for s in ground_model.resolve_stations(ground)} reps = _representatives(r2) for item in reps: chainage = float(item["summary"]["chainage_m"]) item["result"] = _station_result(project_root, item["summary"]["fp"]) try: item["cross"] = source.cross_section(root, chainage) except KeyError: item["cross"] = None item["layers"] = resolved.get(round(chainage, 3)) try: sections3d = run3d.saved_summaries(project_root) except (OSError, ValueError, KeyError): sections3d = [] return { "project": db.get("project") or {}, "route_points": sorted(db.get("route_points") or [], key=lambda p: p["sequence_num"]), "stations": source.stations(root), "result2d": r2, "representatives": reps, "ground": ground, "sections3d": sorted(sections3d, key=lambda s: s["start_m"]), "area": area.read(project_root), "reinforce": reinforce.read(project_root), "fingerprint": analysis_fingerprint(project_root), } # ── 칸 쓰기 ────────────────────────────────────────────────────────────────── def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: """값(None = 지움) · 서식(None = 양식 그대로).""" cell = dict(sheet["칸"].get(a1) or {}) cell.pop("식", None) if value is None or value == "": cell.pop("값", None) else: cell["값"] = round(value, 6) if isinstance(value, float) else value if style is not None: cell["서식"] = style if cell: sheet["칸"][a1] = cell else: sheet["칸"].pop(a1, None) def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None: for col in cols: cell = sheet["칸"].get(f"{col}{src}") if cell and dst != src: sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {} def _shift(a1: str, dr: int) -> str: m = A1.match(a1) return f"{m.group(1)}{int(m.group(2)) + dr}" def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: if ok is None: _set(sheet, a1, None) else: _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float: sf = (ground.get("safety_factors") or {}).get(case) or {} if side in ("cut", "fill"): return float(sf[side]) return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽 def _seismic(ground: dict[str, Any]) -> bool: return bool((ground.get("seismic") or {}).get("enabled")) def _labels(stations: list[dict[str, Any]]) -> dict[float, str]: return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations} def _label_at(labels: dict[float, str], chainage: float) -> str: if not labels: return f"{chainage:.2f}" near = min(labels, key=lambda c: abs(c - chainage)) return labels[near] or f"{chainage:.2f}" # ── 사용자 몫(처음 한 번) ──────────────────────────────────────────────────── def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: """표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀.""" project = inputs["project"] _set(sheet, "C8", project.get("name")) _set( sheet, "C9", " · ".join(str(v) for v in (project.get("region"), project.get("road_type")) if v), ) _set(sheet, "C11", date.today().isoformat()) # ── 프로그램 몫 ────────────────────────────────────────────────────────────── WATER_TEXT = { "none": "지하수 없음", "surface": "지하수위 = 공사 뒤 지표(포화)", "half_height": "비탈면 높이 ½ 심도까지 포화", } def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: """s3 입력조건.""" ground = inputs["ground"] _set(sheet, "B5", "한계평형해석 — Bishop 간편법(기본) · Janbu 보정 · Fellenius 비교") _set(sheet, "B6", "원호(격자 · 진입-탈출) + 비원호(꺾은선) · 측점마다 좌 · 우 비탈 따로") _set( sheet, "B7", "Hungr 3D Bishop(+Janbu 보정) · 구 · 타원체 탐색 — 「3D요약」 시트" if inputs["sections3d"] else "3D 해석 전", ) _set(sheet, "B8", "KDS 11 70 05 비탈면 기준 안전율") for r, case in ((12, "dry"), (13, "wet"), (14, "seismic")): if case == "seismic" and not _seismic(ground): _set(sheet, f"B{r}", "-") _set(sheet, f"C{r}", "지진 검토 안 함") continue sf = ground["safety_factors"][case] _set(sheet, f"B{r}", _criterion(ground, case)) _set(sheet, f"C{r}", f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽") water = ground.get("groundwater") or {} _set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-")) _set( sheet, "B18", f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · " f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}", ) seismic = ground.get("seismic") or {} _set( sheet, "B19", f"건기 수위 + 수평지진계수 kh = {float(seismic.get('kh') or 0):.2f}" if _seismic(ground) else "적용 안 함", ) _set(sheet, "B22", float(ground.get("road_surcharge_kpa") or 0)) defaults = {m["id"]: m for m in ground_model.load_defaults()["materials"]} names = {m["id"]: m["name"] for m in ground.get("materials") or []} mapped = set((ground.get("label_map") or {}).values()) # 지반 라벨에 이은 지층만 「전체」 rows: list[tuple[dict[str, Any], str]] = [ (m, "전체" if m["id"] in mapped else "-") for m in ground.get("materials") or [] ] for zone in ground.get("zones") or []: span = f"{float(zone['from_m']):g} ~ {float(zone['to_m']):g} m" for layer, where in (("soil", "위"), ("rock", "아래")): mid = zone.get(layer) match = next((m for m in ground["materials"] if m["id"] == mid), None) if match: depth = zone.get("soil_thickness_m") if layer == "soil" else None more = f" · 두께 {float(depth):g} m" if depth is not None else "" rows.append((match, f"{span} {where} 지층{more}")) if zone.get("soil_thickness_m") is not None and not (zone.get("soil") or zone.get("rock")): rows.append(({"name": "토층 두께"}, f"{span} · {zone['soil_thickness_m']:g} m")) for i, (m, span) in enumerate(rows): r = 26 + i _copy_row(sheet, 26, r, "ABCDEFG") _set(sheet, f"A{r}", m.get("name") or names.get(m.get("id"), "")) _set(sheet, f"B{r}", span) for col, field in zip("CDEF", ground_model.MATERIAL_FIELDS): _set(sheet, f"{col}{r}", float(m[field]) if field in m else None) base = defaults.get(m.get("id")) same = base and all( float(base[f]) == float(m.get(f, -1)) for f in ground_model.MATERIAL_FIELDS ) _set( sheet, f"G{r}", ("문헌 추정치(기본값)" if same else "사용자 입력") if "c" in m else None ) def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int: """s4 측점표 — 측점 하나 = 한 줄(6 행부터) · N.G 수.""" ground = inputs["ground"] seismic = _seismic(ground) labels = _labels(inputs["stations"]) rows = sorted(inputs["result2d"]["stations"], key=lambda s: float(s["chainage_m"])) ng = 0 for i, st in enumerate(rows): r = 6 + i _copy_row(sheet, 6, r, "ABCDEFGHIJ") side = st.get("side") fs = st.get("fs_by_condition") or {} _set(sheet, f"A{r}", _label_at(labels, float(st["chainage_m"]))) _set(sheet, f"B{r}", float(st["chainage_m"])) _set(sheet, f"C{r}", SIDE_NAMES.get(side, side)) for col, crit, case in (("D", "G", "dry"), ("E", "H", "wet"), ("F", "I", "seismic")): use = case != "seismic" or seismic value = fs.get(case) if use else None _set(sheet, f"{col}{r}", float(value) if value is not None else None) _set(sheet, f"{crit}{r}", _criterion(ground, case, side) if use else None) verdict = st.get("verdict") ok = None if verdict not in ("ok", "ng") else verdict == "ok" ng += ok is False _verdict(sheet, f"J{r}", ok) return ng def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str): """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이.""" best = None for case in (result or {}).get("cases") or []: if case.get("condition") != condition: continue if side in ("cut", "fill") and case.get("role") not in (side, None): continue methods = (case.get("result") or {}).get("methods") or {} found = (methods.get(method) or {}).get("min") or next( (v["min"] for v in methods.values() if v.get("min")), None ) if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]): best = (found, case) return best def _block_copy(sheet: dict[str, Any], template: dict[str, Any], k: int) -> None: """양식 1~28 행 덩이를 k 번째 자리로(칸 · 병합 · 행 높이 · 조건부서식).""" dr = BLOCK * k for a1, cell in template["칸"].items(): if int(A1.match(a1).group(2)) <= BLOCK: sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell) for m in template.get("병합", []): sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":"))) for r, info in (template.get("행") or {}).items(): if int(r) <= BLOCK: sheet["행"][str(int(r) + dr)] = dict(info) for rule in template.get("조건부서식", []): moved = copy.deepcopy(rule) moved["id"] = f"{rule['id']}_{k}" moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]] sheet["조건부서식"].append(moved) CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]: """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)].""" template = copy.deepcopy(sheet) reps = inputs["representatives"] ground = inputs["ground"] labels = _labels(inputs["stations"]) sheet["칸"] = {} sheet["병합"], sheet["행"], sheet["조건부서식"], sheet["그림"] = [], {}, [], [] if not reps: _block_copy(sheet, template, 0) _set(sheet, "A1", "대표 단면 — 해당 없음") sheet["인쇄"]["쪽나눔"] = [] return [] jobs: list[tuple[str, str]] = [] for k, rep in enumerate(reps): dr = BLOCK * k _block_copy(sheet, template, k) st = rep["summary"] side = st.get("side") method = st.get("method") or "bishop" label = _label_at(labels, float(st["chainage_m"])) picks = {c: _case_slip(rep.get("result"), c, side, method) for c in CASES} dry = picks.get("dry") height = float(dry[1].get("height_m") or 0) if dry else 0.0 mark = CIRCLED[k] if k < len(CIRCLED) else str(k + 1) _set( sheet, f"A{1 + dr}", f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m", ) reason = REASONS.get(rep.get("reason"), rep.get("reason") or "") if rep.get("reason") == "lowest": reason = f"{reason} {LOWEST_N}" _set(sheet, f"B{2 + dr}", reason) key = fig_key(rep["station_key"]) sheet["그림"].append( {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"} ) fs = st.get("fs_by_condition") or {} slips = [] for r, case in zip((24, 25, 26), CASES): row = r + dr if case == "seismic" and not _seismic(ground): continue value = fs.get(case) crit = _criterion(ground, case, side) pick = picks.get(case) _set(sheet, f"B{row}", METHOD_NAMES.get(method, method)) _set(sheet, f"C{row}", float(value) if value is not None else None) _set(sheet, f"D{row}", crit) _verdict(sheet, f"E{row}", None if value is None else float(value) >= crit - 1e-9) slip = pick[0] if pick else None center = (slip or {}).get("center") _set(sheet, f"F{row}", float(slip["radius"]) if slip and slip.get("radius") else None) _set(sheet, f"G{row}", float(center[0]) if center else None) _set(sheet, f"H{row}", float(center[1]) if center else None) if slip and slip.get("points"): slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]}) design = (rep.get("cross") or {}).get("design") or {} kind = (dry[0].get("kind") if dry else None) or "" layers = rep.get("layers") or {} note = [ {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""), f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}", f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "", ] _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n)) jobs.append((key, _section_figure(rep, slips, f"{label} — {reason}"))) sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))] return jobs def _section_figure(rep: dict[str, Any], slips: list[dict[str, Any]], title: str) -> str: cross = rep.get("cross") or {} design = cross.get("design") or {} ground = sorted( (float(s["offset_m"]), float(s["z"])) for s in cross.get("samples") or [] if s.get("valid", True) and s.get("z") is not None ) line = sorted( (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] ) surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground thickness = (rep.get("layers") or {}).get("soil_thickness_m") rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None edges = design.get("road_edges") or {} road = None if edges.get("left") and edges.get("right"): road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"])) heights = [] if road and line: for case in (rep.get("result") or {}).get("cases") or []: if case.get("condition") != "dry" or not case.get("height_m"): continue left = case.get("side") == "left" edge = road[1] if left else road[0] day = line[-1][0] if left else line[0][0] z0, z1 = figure._interp(surface, edge), figure._interp(surface, day) if z0 is not None and z1 is not None: heights.append({"x": day, "z0": z0, "z1": z1}) return figure.section_svg( title=title, ground=ground, surface=surface, rock=rock, slips=slips, road=road, heights=heights, wet=any(s["condition"] == "wet" for s in slips), ) def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]: """s6 3D요약 — 구간 하나 = 한 줄(20 행부터) · 평면 위험 띠 그림 일감.""" ground = inputs["ground"] labels = _labels(inputs["stations"]) rows2d = inputs["result2d"]["stations"] sections = [] for i, sec in enumerate(inputs["sections3d"]): r = 20 + i _copy_row(sheet, 20, r, "ABCDEGH") start, end = float(sec["start_m"]), float(sec["end_m"]) cond = sec.get("condition") or "dry" # 2D 는 3D 와 같은 조건끼리 견줌 inside = [ float(s["min_by_condition"][cond]) for s in rows2d if s["min_by_condition"].get(cond) is not None and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6 ] f3 = float(sec["f_min"]) if sec.get("verdict") in ("ok", "ng"): # 판정 = E04 엔진 값(기준안전율도 E04) ok = sec["verdict"] == "ok" else: ok = f3 >= _criterion(ground, cond) - 1e-9 _set(sheet, f"A{r}", f"{i + 1}구간" + (" (미리보기)" if sec.get("preview") else "")) _set(sheet, f"B{r}", _label_at(labels, start)) _set(sheet, f"C{r}", _label_at(labels, end)) _set(sheet, f"D{r}", f3) _set(sheet, f"E{r}", min(inside) if inside else None) sheet["칸"][f"F{r}"] = { "식": f'IF(E{r}>0,D{r}/E{r},"")', "서식": sheet["칸"]["F20"]["서식"], } _set(sheet, f"G{r}", float(sec.get("volume") or 0)) _verdict(sheet, f"H{r}", ok) sections.append({"start_m": start, "end_m": end, "ok": ok}) if not inputs["sections3d"]: _set(sheet, "A20", "3D 해석 전") xy = {round(float(s["chainage_m"]), 3): s for s in inputs["stations"]} marks = [] for st in sorted(rows2d, key=lambda s: float(s["chainage_m"])): at = xy.get(round(float(st["chainage_m"]), 3)) if at and at.get("center_x") is not None: verdict = st.get("verdict") marks.append( { "x": float(at["center_x"]), "y": float(at["center_y"]), "ok": None if verdict not in ("ok", "ng") else verdict == "ok", "label": at.get("label"), } ) line = [ (float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])) for p in inputs["route_points"] ] svg = figure.band_svg( title="평면 위험 띠 — 2D 측점 판정 · 3D 구간", line=line, stations=marks, sections=sections ) return BAND_KEY, svg def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]: """양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감.""" by = {s["id"]: s for s in book["시트"]} fill_conditions(by["s3"], inputs) fill_stations(by["s4"], inputs) jobs = fill_sections(by["s5"], inputs) jobs.append(fill_3d(by["s6"], inputs)) area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) 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"] return jobs def sheet_sign(sheet: dict[str, Any]) -> str: """시트 서명 — 칸 값 · 식 · 서식 · 병합 · 그림 · 행열 크기 (빈 칸 · 빈 꾸밈은 뺌) · 덮임 알림용.""" cells = { a1: [c.get("값"), c.get("식"), c.get("서식", 0)] for a1, c in (sheet.get("칸") or {}).items() if c.get("값") is not None or c.get("식") or c.get("서식") } body = { "칸": cells, "병합": sorted(sheet.get("병합") or []), "그림": sheet.get("그림") or [], "행": {k: v for k, v in (sheet.get("행") or {}).items() if v}, "열": {k: v for k, v in (sheet.get("열") or {}).items() if v}, } text = json.dumps(_numbers(body), ensure_ascii=False, sort_keys=True) return hashlib.sha1(text.encode("utf-8")).hexdigest() def _numbers(value: Any) -> Any: """수는 float 로 — 화면(JS)을 거치면 10.0 이 10 으로 옴(같은 값을 다르다고 보지 않게).""" if isinstance(value, dict): return {k: _numbers(v) for k, v in value.items()} if isinstance(value, list): return [_numbers(v) for v in value] if isinstance(value, int | float) and not isinstance(value, bool): return round(float(value), 9) return value