diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py
index acb6df786..215ed20a3 100644
--- a/E05_StabReport/E05_StabReport_Build.py
+++ b/E05_StabReport/E05_StabReport_Build.py
@@ -37,6 +37,7 @@ from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary
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_Maps as maps
from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Prob as prob
from E05_StabReport import E05_StabReport_Reinforce as reinforce
@@ -225,6 +226,7 @@ def gather(project_root: Path) -> dict[str, Any]:
"rock": rock.read(project_root),
"prob": prob.read(project_root),
"fingerprint": analysis_fingerprint(project_root),
+ "project_root": project_root, # 그림 셋이 저장본 격자를 읽음
}
@@ -420,7 +422,7 @@ def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]:
"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"),
+ "label": _label_at(labels, float(st["chainage_m"])),
}
)
line = [
@@ -460,6 +462,7 @@ def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[st
prob.fill_prob(by["s12"], inputs["prob"], _labels(inputs["stations"]))
else: # 확률 해석이 없으면 시트를 뺌
book["시트"] = [s for s in book["시트"] if s["id"] != "s12"]
+ jobs += maps.figure_jobs(by, inputs, inputs["project_root"])
printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc`
return jobs
diff --git a/E05_StabReport/E05_StabReport_Maps.py b/E05_StabReport/E05_StabReport_Maps.py
new file mode 100644
index 000000000..066d9edf8
--- /dev/null
+++ b/E05_StabReport/E05_StabReport_Maps.py
@@ -0,0 +1,329 @@
+"""E05 보고서 그림 셋 — 3D 안전율 지도(E04) · 스테레오넷(E06) · 광역 위험 지도(E07)
+(요구 390 4 · PLAN 565-2).
+
+ map3d_svg(docs, line, ends, condition) 3D 구간 격자(셀 최소 Fs) · 노선 · 첫 · 끝 측점
+ stereonet_svg(net, title) E06 측점 하나 쪽 평사투영 — 위험 영역 · 대원 · 극점 · 교선
+ area_svg(doc, line, ends) E07 광역 등급(SI) 격자 · 노선 (그림 셋 다 684 × 460)
+ figure_jobs(by, inputs, root) 시트(s6 · s7 · s11) 표 밑에 그림 붙임 · 일감 [(열쇠, SVG)]
+
+격자는 PNG(data: 주소)로 SVG 안에 — 셀 하나 = 화소 하나(값 셈 없음 · 저장본 그대로 칠함).
+색 = 화면 범례와 같은 값(E04 `logic/E04_Stab3D_Raster.ts` FS_STEPS ·
+E07 `logic/E07_StabArea_Raster.ts` CLASS_COLORS) — 한쪽을 고치면 다른 쪽도.
+"""
+
+from __future__ import annotations
+
+import base64
+import io
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+from PIL import Image
+
+from E04_Stab3D import E04_Stab3D_Run as run3d
+from E05_StabReport.E05_StabReport_Cells import _label_at, _labels, _set
+from E05_StabReport.E05_StabReport_Figure import INK, MUTED, _empty, _poly, _text
+from E06_StabRock import E06_StabRock_Model as rock_model
+from E07_StabArea.E07_StabArea_Router import load as load_area
+
+W, H = 684, 460
+PAD, TOP, LEGEND = 24, 30, 46
+MAP3D_KEY, STEREONET_KEY, AREA_KEY = "fig/3d_map.png", "fig/stereonet.png", "fig/area_map.png"
+FS_STEPS = ( # (미만, 색) — E04 화면 FS_STEPS
+ (1.0, "#a50026"),
+ (1.2, "#d73027"),
+ (1.3, "#f46d43"),
+ (1.5, "#fdae61"),
+ (2.0, "#fee08b"),
+ (3.0, "#a6d96a"),
+ (float("inf"), "#1a9850"),
+)
+CLASS_COLORS = ("#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027")
+CLASS_NAMES = ("안정", "보통 안정", "준안정", "하한 불안정", "상한 불안정", "불안정")
+CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"}
+ROCK_ORDER = {"NG": 0, "check": 1, "OK": 2, "none": 3}
+ZONE_COLORS = {"planar": "#d73027", "wedge": "#fc8d59", "toppling": "#7b1fa2"}
+ZONE_NAMES = {"planar": "평면", "wedge": "쐐기", "toppling": "전도"}
+JOINT_COLORS = ("#1565c0", "#2e7d32", "#ef6c00", "#6a1b9a", "#00838f", "#5d4037")
+
+
+def _rgb(hex_color: str) -> tuple[int, int, int]:
+ n = int(hex_color[1:], 16)
+ return (n >> 16) & 255, (n >> 8) & 255, n & 255
+
+
+def _png_uri(rgba: np.ndarray) -> str:
+ buf = io.BytesIO()
+ Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
+
+
+def _paint(values: np.ndarray, colors: list[tuple[int, int, int]]) -> np.ndarray:
+ """색 번호 격자(-1 = 비움 · 줄 0 = 남쪽) → RGBA(줄 0 = 북쪽)."""
+ rgba = np.zeros((*values.shape, 4), np.uint8)
+ for k, color in enumerate(colors):
+ rgba[values == k] = (*color, 255)
+ return rgba[::-1]
+
+
+def _frame(boxes: list[tuple[float, float, float, float]]):
+ """모델 상자들(x0, y0, x1, y1) → 화면 변환(북 = 위 · 같은 축척)."""
+ x0 = min(b[0] for b in boxes)
+ y0 = min(b[1] for b in boxes)
+ x1 = max(b[2] for b in boxes)
+ y1 = max(b[3] for b in boxes)
+ w, h = W - 2 * PAD, H - TOP - LEGEND - PAD / 2
+ scale = min(w / max(x1 - x0, 1e-6), h / max(y1 - y0, 1e-6))
+ ox = PAD + (w - (x1 - x0) * scale) / 2
+ oy = TOP + (h - (y1 - y0) * scale) / 2
+
+ def sp(x: float, y: float) -> tuple[float, float]:
+ return ox + (x - x0) * scale, oy + (y1 - y) * scale
+
+ return sp, scale
+
+
+def _grid_box(grid: dict[str, Any]) -> tuple[float, float, float, float]:
+ half = grid["dx"] / 2
+ return (
+ grid["x0"] - half,
+ grid["y0"] - half,
+ grid["x0"] + (grid["nx"] - 0.5) * grid["dx"],
+ grid["y0"] + (grid["ny"] - 0.5) * grid["dx"],
+ )
+
+
+def _image(grid: dict[str, Any], uri: str, sp, scale: float) -> str:
+ x0, _, _, y1 = _grid_box(grid)
+ a, b = sp(x0, y1)
+ return (
+ f""
+ )
+
+
+def _head(title: str) -> list[str]:
+ return [
+ f"")
+ return "".join(out)
+
+
+def area_svg(doc: dict[str, Any] | None, line, ends) -> str:
+ title = "광역 자연사면 위험 지도 — 안정 등급(SI)"
+ if not doc:
+ return _empty(W, H, title)
+ g = doc["grid"]
+ cls = np.frombuffer(base64.b64decode(doc["cls"]), np.uint8).reshape(g["ny"], g["nx"])
+ sp, scale = _frame([_grid_box(g)] + [(x, y, x, y) for x, y in line])
+ out = _head(title)
+ rgba = _paint(cls.astype(int) - 1, [_rgb(c) for c in CLASS_COLORS])
+ out.append(_image(g, _png_uri(rgba), sp, scale))
+ out += _route(line, ends, sp)
+ out += _legend(list(zip(CLASS_COLORS, CLASS_NAMES)))
+ out.append("")
+ return "".join(out)
+
+
+def stereonet_svg(net: dict[str, Any], title: str) -> str:
+ """하반구 투영 — 반지름 1 원 · x = 동 · y = 북."""
+ r0 = (H - TOP - LEGEND - 10) / 2
+ cx, cy = W / 2, TOP + 6 + r0
+
+ def sp(x: float, y: float) -> tuple[float, float]:
+ return cx + x * r0, cy - y * r0
+
+ def path(points: list[list[float]], closed: bool = False) -> str:
+ d = " ".join(f"{a:.1f},{b:.1f}" for a, b in (sp(*p) for p in points))
+ return f"M{d}{' Z' if closed else ''}"
+
+ out = _head(title)
+ out.append(f"")
+ for mode, zone in (net.get("zones") or {}).items():
+ if zone:
+ color = ZONE_COLORS[mode]
+ out.append(
+ f""
+ )
+ for r, dash in ((net["friction_pole_r"], "5 3"), (net["friction_line_r"], "2 3")):
+ out.append(
+ f""
+ )
+ out.append(
+ f""
+ )
+ legend = [(INK, "비탈면")]
+ for k, plane in enumerate(net.get("planes") or []):
+ color = JOINT_COLORS[k % len(JOINT_COLORS)]
+ out.append(
+ f""
+ )
+ a, b = sp(*plane["pole"])
+ out.append(f"")
+ legend.append((color, plane.get("name") or plane["id"]))
+ for item in net.get("intersections") or []:
+ a, b = sp(*item["point"])
+ out.append(
+ f""
+ )
+ out.append(_text(cx, cy - r0 - 4, "N", 11, font_weight="bold", fill=INK))
+ out += _legend(legend, "line")
+ zx = W - PAD
+ for mode in ("toppling", "wedge", "planar"):
+ label = f"{ZONE_NAMES[mode]} 위험 영역"
+ out.append(_text(zx, H - 34, label, 10, "end", fill=ZONE_COLORS[mode]))
+ zx -= 14 + 11 * len(label)
+ out.append("")
+ return "".join(out)
+
+
+# ── 시트에 붙임 ──────────────────────────────────────────────────────────────
+def _last_row(sheet: dict[str, Any]) -> int:
+ rows = [int("".join(ch for ch in a1 if ch.isdigit())) for a1 in sheet["칸"]]
+ for fig in sheet.get("그림") or []:
+ rows.append(int("".join(ch for ch in fig["닻"].split(":")[-1] if ch.isdigit())))
+ return max(rows, default=0)
+
+
+def _attach(sheet: dict[str, Any], key: str, caption: str) -> None:
+ """표 밑 한 줄 띄고 「n. 제목」 줄(서식 3 · A:H 병합) + 그림 한 줄(그림 높이 ·
+ 한 행이라 쪽에서 안 끊김)."""
+ n = 1 + sum(
+ 1
+ for c in sheet["칸"].values()
+ if c.get("서식") == 3 and str(c.get("값") or "")[:1].isdigit()
+ )
+ r = _last_row(sheet) + 2
+ _set(sheet, f"A{r}", f"{n}. {caption}", 3)
+ sheet.setdefault("병합", []).append(f"A{r}:H{r}")
+ rows = sheet.setdefault("행", {})
+ rows[str(r)] = {"높이": 22}
+ rows[str(r + 1)] = {"높이": round(H * 0.75)}
+ sheet.setdefault("그림", []).append({"열쇠": key, "닻": f"A{r + 1}:H{r + 1}", "이름": caption})
+
+
+def _line_ends(inputs: dict[str, Any]):
+ line = [(float(p["model_x"]), float(p["model_y"])) for p in inputs["route_points"]]
+ labels = _labels(inputs["stations"])
+ ends = []
+ for p in (inputs["route_points"][0], inputs["route_points"][-1]) if line else ():
+ ends.append(
+ (float(p["model_x"]), float(p["model_y"]), _label_at(labels, float(p["chainage_m"])))
+ )
+ return line, ends
+
+
+def _rock_pick(rock: dict[str, Any]) -> dict[str, Any] | None:
+ """스테레오넷을 그릴 측점 — 판정 나쁜 쪽 · 같으면 최소 Fs 낮은 쪽."""
+ entries = [e for e in rock.get("stations") or [] if e.get("joints")]
+ return min(
+ entries,
+ key=lambda e: (
+ ROCK_ORDER.get(e.get("verdict"), 9),
+ e.get("min_F") is None,
+ e.get("min_F") or 0,
+ ),
+ default=None,
+ )
+
+
+def figure_jobs(by: dict[str, dict], inputs: dict[str, Any], root: Path) -> list[tuple[str, str]]:
+ """결과가 있는 그림만 시트에 붙이고 그림 일감 — 3D(s6) · 광역(s7) · 암반(s11)."""
+ jobs: list[tuple[str, str]] = []
+ line, ends = _line_ends(inputs)
+ secs = inputs["sections3d"]
+ if secs:
+ conds = {s.get("condition") or "dry" for s in secs}
+ cond = "dry" if "dry" in conds else sorted(conds)[0]
+ docs = [run3d.load(root, s["key"]) for s in secs if (s.get("condition") or "dry") == cond]
+ docs = [d for d in docs if d and d.get("grid") and d.get("f")]
+ if docs:
+ _attach(by["s6"], MAP3D_KEY, "3D 안전율 지도")
+ jobs.append((MAP3D_KEY, map3d_svg(docs, line, ends, cond)))
+ if inputs.get("area"):
+ doc = load_area(root)
+ if doc and doc.get("grid") and doc.get("cls"):
+ _attach(by["s7"], AREA_KEY, "광역 위험 지도")
+ jobs.append((AREA_KEY, area_svg(doc, line, ends)))
+ rock = inputs.get("rock")
+ entry = _rock_pick(rock) if rock else None
+ if entry:
+ settings = rock["settings"]
+ kind = settings.get("projection") or "equal_angle"
+ net = rock_model.station_net(entry, settings)[kind]
+ names = {j["id"]: j.get("name") or j["id"] for j in entry["joints"]}
+ for plane in net.get("planes") or []:
+ plane["name"] = names.get(plane["id"], plane["id"])
+ label = _label_at(_labels(inputs["stations"]), float(entry["chainage_m"]))
+ side = {"left": "좌", "right": "우"}.get(entry["slope"].get("side"), "")
+ proj = "등각(Wulff)" if kind == "equal_angle" else "등면적(Schmidt)"
+ caption = f"평사투영 — {label} {side} · 하반구 {proj}"
+ _attach(by["s11"], STEREONET_KEY, caption)
+ jobs.append((STEREONET_KEY, stereonet_svg(net, caption)))
+ return jobs
diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py
index 28961e1ec..3b86deebc 100644
--- a/E05_StabReport/E05_StabReport_Router.py
+++ b/E05_StabReport/E05_StabReport_Router.py
@@ -1,7 +1,8 @@
"""E05 안정성평가 보고서 API (요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
- `GET /api/projects/{id}/stab/report` — 작업본(없고 2D 결과가 있으면 처음 만듦 + 초기값) · 판 ·
- 해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지
+ 해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지 · 고칠 수 있으면 낡은 작업본을 저절로 갱신
+ (`refreshed` · `overwritten` — 페이지 단독 [해석] 뒤)
- `PUT …/stab/report` — [저장] `{rev, book}` · 판이 다르면 409 · 서버 재계산(`with_server_values`)
- `POST …/stab/report/refresh` — [보고서 갱신] `{rev}` · 프로그램 몫 시트 · 그림만 다시 ·
덮인 시트에 사용자 고침이 있었으면 `overwritten` + 옛 판 `_지난/`
@@ -97,14 +98,23 @@ def _state(root: Path) -> dict[str, Any]:
}
-def _ensure(root: Path) -> dict[str, Any]:
- """작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭)."""
+def _ensure(root: Path, refresh: bool = False) -> dict[str, Any]:
+ """작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭) · `refresh` 면 페이지 단독
+ [해석] 으로 낡은 작업본을 저절로 갱신(`refreshed` · 덮인 시트 `overwritten`)."""
if store.load(root)[0] is None:
try:
store.create(root)
except build.MissingInput as error:
return {"book": None, "reason": str(error)}
- return _state(root)
+ return _state(root)
+ state = _state(root)
+ if not (refresh and state["stale"]):
+ return state
+ try:
+ result = store.refresh(root)
+ except build.MissingInput:
+ return state
+ return {**_state(root), "refreshed": True, "overwritten": result["overwritten"]}
def _recalc_errors(error: Exception) -> HTTPException:
@@ -118,12 +128,13 @@ async def get_report(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root, company_id = await _project(project_id)
+ editable = _editable(session, company_id)
async with _lock(project_id):
try:
- state = await asyncio.to_thread(_ensure, root)
+ state = await asyncio.to_thread(_ensure, root, editable)
except (RecalcError, PrintError) as error:
raise _recalc_errors(error) from error
- return {"status": "success", "editable": _editable(session, company_id), **state}
+ return {"status": "success", "editable": editable, **state}
@router.put("/{project_id}/stab/report", response_model=None)
diff --git a/E05_StabReport/E05_StabReport_UI_Page.ts b/E05_StabReport/E05_StabReport_UI_Page.ts
index 402d15549..9e345aa5a 100644
--- a/E05_StabReport/E05_StabReport_UI_Page.ts
+++ b/E05_StabReport/E05_StabReport_UI_Page.ts
@@ -5,6 +5,7 @@
* 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴).
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) ·
* [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시).
+ * 열 때 해석이 바뀌었으면 서버가 저절로 갱신(`refreshed` · 덮인 시트 `overwritten` 알림).
* [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함.
* ========================================================================== */
@@ -42,6 +43,7 @@ interface ReportResponse {
editable?: boolean;
reason?: string;
overwritten?: string[];
+ refreshed?: boolean;
}
interface CacheEntry {
@@ -317,7 +319,12 @@ export function renderE05StabReport(root: HTMLElement): void {
void run(async () => {
try {
- await adopt(await request(`/projects/${projectId}/stab/report`), true);
+ const body: ReportResponse = await request(`/projects/${projectId}/stab/report`);
+ await adopt(body, true);
+ // 페이지 단독 [해석] 뒤 서버가 저절로 갱신(565-2)
+ const over = body.overwritten ?? [];
+ if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
+ else if (body.refreshed) showToast(t("E05_Refreshed"), "success");
} catch (error) {
sheetHost.replaceChildren(
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py
index f4021c226..2d467426f 100644
--- a/resources/tester/test_534_3_e05_report.py
+++ b/resources/tester/test_534_3_e05_report.py
@@ -7,6 +7,7 @@
목차 쪽 번호 = 실제 · 부록 절편 · 측점표 쪽 나눔 고르게 · 옛 작업본 갱신(목차 자리 · 서식 붙임).
"""
+import base64
import io
import json
import shutil
@@ -21,6 +22,7 @@ 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_Maps as maps
from E05_StabReport import E05_StabReport_Print as printer
from E05_StabReport import E05_StabReport_Store as store
@@ -175,6 +177,8 @@ def project(tmp_path, monkeypatch) -> Path:
{
"fingerprint": "3d-1",
"settings": {},
+ "grid": {"x0": 0.0, "y0": 0.0, "dx": 10.0, "nx": 3, "ny": 2}, # 565-2 3D 지도
+ "f": [1.1, 1.6, None, 2.5, 0.9, 3.2],
"summary": {
"start_m": 0.0,
"end_m": 200.0,
@@ -252,7 +256,14 @@ def test_처음_만듦_측점수_NG_그림(project):
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 len(figs) == len(REPS) + 2 and meta["figures"][-2:] == [build.BAND_KEY, maps.MAP3D_KEY]
+ # 565-2 3D 안전율 지도 = 3D요약 표 밑 「3. …」 줄 + 그림 한 행(우기 구간뿐 → 우기)
+ pic = by["3D요약"]["그림"][-1]
+ assert pic["열쇠"] == maps.MAP3D_KEY and pic["닻"] == "A23:H23"
+ assert by["3D요약"]["칸"]["A22"]["값"] == "3. 3D 안전율 지도"
+ assert "광역요약" not in [
+ s["이름"] for s in book["시트"] if s.get("그림")
+ ] # 광역 전 = 그림 없음
assert [f["열쇠"] for f in by["대표단면"]["그림"]] == [build.fig_key(k) for k, _ in REPS]
assert by["대표단면"]["인쇄"]["쪽나눔"] == [28, 56, 84]
assert by["대표단면"]["칸"]["B30"]["값"] == "깎기 높이 15 m 이상"
@@ -297,6 +308,8 @@ def test_광역요약_E07_저장본(project):
"level_hi_pct": 20.0,
},
"computed_at": "2026-10-25T21:00:00",
+ "grid": {"x0": 0.0, "y0": 0.0, "dx": 2.0, "nx": 2, "ny": 2}, # 565-2 광역 지도
+ "cls": base64.b64encode(bytes([1, 6, 0, 4])).decode("ascii"),
"stations": [
{
"chainage_m": 0.0,
@@ -348,7 +361,12 @@ def test_광역요약_E07_저장본(project):
book, _ = store.load(project)
s7 = _sheets(book)["광역요약"]["칸"]
assert [s7[f"C{r}"]["값"] for r in range(12, 18)] == [100.0, 20.0, 12.0, 8.0, 0.0, 36.0]
- assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["1+0", "2+0"] and "A24" not in s7
+ assert [s7[f"A{r}"]["값"] for r in (22, 23)] == ["1+0", "2+0"]
+ assert s7["A25"]["값"] == "4. 광역 위험 지도" # 565-2 위험 측점 표 밑
+ assert _sheets(book)["광역요약"]["그림"] == [
+ {"열쇠": maps.AREA_KEY, "닻": "A26:H26", "이름": "광역 위험 지도"}
+ ]
+ assert build.fig_path(build.stage_dir(project), maps.AREA_KEY).is_file()
assert (
s7["E22"]["값"] == "높음 (100.0 %)"
and s7["E23"]["값"] == "보통 (5.6 %)"
@@ -439,12 +457,12 @@ def test_xlsx_되읽기_pdf_쪽수(project):
_create(project)
book, _ = store.load(project)
figs = store.figures_of(project, book)
- assert len(figs) == len(REPS) + 1
+ assert len(figs) == len(REPS) + 2 # 대표 단면 · 위험 띠 · 3D 지도(565-2)
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
assert wb.sheetnames == [
"표지", "목차", "개요", "입력조건", "측점표", "대표단면", "3D요약", "광역요약", "부록_절편",
] # fmt: skip
- assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 1
+ assert len(wb["대표단면"]._images) == len(REPS) and len(wb["3D요약"]._images) == 2
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"
@@ -458,8 +476,9 @@ def test_xlsx_되읽기_pdf_쪽수(project):
texts = [p.extract_text() for p in PdfReader(io.BytesIO(pdf)).pages]
stab = [i for i, t in enumerate(texts) if "산정 안전율" in t]
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)
+ # 표지 1 + 목차 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 3 + 3D 2(지도) + 광역 1 + 부록 3
+ assert len(texts) == 4 + len(stab) + len(REPS) + 2 + 1 + len(REPS)
+ assert sum("3. 3D 안전율 지도" in t for t in texts) == 1 # 565-2 그림 제목 줄
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))
@@ -524,6 +543,16 @@ def test_길_저장_409_갱신_초기화_내보내기(project, monkeypatch):
assert x.status_code == 200 and load_workbook(io.BytesIO(x.content)).sheetnames[0] == "표지"
back = client.post(f"{url}/reset").json()
assert "값" not in _sheets(back["book"])["표지"]["칸"].get("C10", {})
+ # 565-2 페이지 단독 [해석](3D 저장본 바뀜) 뒤 열면 저절로 갱신 · 보기만 회사는 띠만
+ r3 = project / "E04_Stab3D" / "results" / "0.00_200.00_wet.json"
+ r3.write_text(r3.read_text(encoding="utf-8").replace("1.42", "1.40"), encoding="utf-8")
+ app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 9}
+ view = client.get(url).json()
+ assert view["stale"] is True and "refreshed" not in view
+ app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 3}
+ auto = client.get(url).json()
+ assert auto["refreshed"] is True and auto["stale"] is False and auto["overwritten"] == []
+ assert _sheets(auto["book"])["3D요약"]["칸"]["D20"]["값"] == 1.40
def test_서명_화면_왕복_같음(project):
@@ -534,3 +563,26 @@ def test_서명_화면_왕복_같음(project):
assert s3["칸"]["B22"]["값"] == 10.0
trip = json.loads(json.dumps(s3).replace("10.0", "10"))
assert trip["칸"]["B22"]["값"] == 10 and build.sheet_sign(trip) == build.sheet_sign(s3)
+
+
+def test_565_2_스테레오넷_측점_고름_그림():
+ """암반 그림 = 판정 나쁜 측점(NG > 검토 > O.K) · 같으면 최소 Fs 낮은 쪽."""
+ from E06_StabRock import E06_StabRock_Model as rock_model
+
+ joints = [
+ {"id": "J1", "name": "J1", "dip_direction": 115.0, "dip": 45.0, "phi": 30.0, "c": 0.0},
+ {"id": "J2", "name": "J2", "dip_direction": 200.0, "dip": 70.0, "phi": 30.0, "c": 0.0},
+ ]
+ slope = {"dip_direction": 110.0, "dip": 70.0, "side": "left", "height_m": 8.0}
+ rows = [
+ {"chainage_m": 0.0, "verdict": "OK", "min_F": 0.9, "joints": joints, "slope": slope},
+ {"chainage_m": 20.0, "verdict": "NG", "min_F": 1.2, "joints": joints, "slope": slope},
+ {"chainage_m": 40.0, "verdict": "NG", "min_F": 1.05, "joints": joints, "slope": slope},
+ ]
+ assert maps._rock_pick({"stations": rows})["chainage_m"] == 40.0
+ net = rock_model.station_net(
+ rows[2], {"lateral_planar_deg": 20.0, "lateral_toppling_deg": 10.0}
+ )
+ svg = maps.stereonet_svg(net["equal_angle"], "평사투영")
+ assert svg.count("r='3.5'") == 2 and "평면 위험 영역" in svg # 극점 둘
+ assert svg.count("= 4 # 비탈 · 절리 대원 둘 · 위험 영역