fix(534-4): E05 보고서가 E03 실제 2D 저장본을 읽음 — 가상 result2d.json · results/ 대신 stab2d_summary(가장 최근) · stab2d_station · 대표 단면 = E03 자동 + 켬/끔 · 비탈 = 기준 대비 가장 낮은 쪽 · 시험 꼴도 E03 저장본으로
전 흐름 확인에서 E03 [해석] 뒤에도 E05 가 「2D 해석 결과가 없습니다」 — 534-3 은 정한 가상 꼴로만 시험함. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3).
|
||||
|
||||
입력 = E02 지반 조건(`ground.json`) · E03 2D 결과 요약(`_derived/result2d.json`) · 측점별 저장본 ·
|
||||
대표 단면(`representative.json`) · E04 3D 저장본 요약(있으면) · E01 복사본(측점 · 횡단 · 노선).
|
||||
입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별
|
||||
`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약(있으면) ·
|
||||
E01 복사본(측점 · 횡단 · 노선).
|
||||
양식 = `resources/master_template/report/stab_report.json`
|
||||
칸 자리 = 같은 폴더 `stab_report_slots.md`.
|
||||
|
||||
@@ -28,7 +29,10 @@ from typing import Any
|
||||
|
||||
from E01_StabRegister import E01_StabRegister_Source as source
|
||||
from E02_StabGround import E02_StabGround_Model as ground_model
|
||||
from common_util.common_util_derived_store import read_derived
|
||||
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_Figure as figure
|
||||
|
||||
@@ -39,12 +43,9 @@ REPORT, META = "report.json", "report_meta.json"
|
||||
USER_SHEETS = ("s1", "s2")
|
||||
PROGRAM_SHEETS = ("s3", "s4", "s5", "s6")
|
||||
BLOCK = 28 # 대표단면 한 쪽 = 28 행
|
||||
E03 = Path("E03_StabSection")
|
||||
RESULT2D = E03 / "_derived" / "result2d.json"
|
||||
STATION_RESULTS = E03 / "_derived" / "results"
|
||||
REPRESENTATIVE = E03 / "representative.json"
|
||||
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
|
||||
BAND_KEY = "fig/3d_band.png"
|
||||
LOWEST_N = 5 # representative.json 이 없을 때 — 최소 안전율 하위 N
|
||||
LOWEST_N = view2d.REPRESENTATIVE_LOWEST
|
||||
CASES = ("dry", "wet", "seismic")
|
||||
SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"}
|
||||
METHOD_NAMES = {
|
||||
@@ -55,9 +56,9 @@ METHOD_NAMES = {
|
||||
"spencer": "Spencer",
|
||||
}
|
||||
REASONS = {
|
||||
"fill_10m": "쌓기 높이 10 m 이상",
|
||||
"cut_15m": "깎기 높이 15 m 이상",
|
||||
"lowest_fs": "최소 안전율 하위",
|
||||
"fill_height": "쌓기 높이 10 m 이상",
|
||||
"cut_height": "깎기 높이 15 m 이상",
|
||||
"lowest": "최소 안전율 하위",
|
||||
"user": "사용자 지정",
|
||||
}
|
||||
GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"}
|
||||
@@ -104,23 +105,62 @@ def _ground(project_root: Path) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _station_result(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||
folder = Path(project_root) / STATION_RESULTS
|
||||
for name in (key, re.sub(r"[^0-9A-Za-z._+-]", "_", key)):
|
||||
found = _read(folder / f"{name}.json")
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
def _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,
|
||||
"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 = _read(project_root / RESULT2D)
|
||||
r2, _ = _latest_summary(project_root, "")
|
||||
folder = run3d.results_dir(project_root)
|
||||
body = {
|
||||
"2d": [r2.get("input_fingerprint"), r2.get("computed_at")] if r2 else None,
|
||||
"2d": [r2.get("fingerprint"), r2.get("computed_at")] if r2 else None,
|
||||
"rep": _read(project_root / REPRESENTATIVE),
|
||||
"ground": _ground(project_root),
|
||||
"3d": [
|
||||
@@ -134,36 +174,31 @@ def analysis_fingerprint(project_root: Path) -> str:
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _representatives(r2: dict[str, Any], rep: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
by_key = {s["station_key"]: s for s in r2.get("stations") or []}
|
||||
if rep and isinstance(rep.get("stations"), list):
|
||||
picked = [s for s in rep["stations"] if s.get("enabled", True)]
|
||||
else:
|
||||
ranked = sorted(
|
||||
(s for s in by_key.values() if s.get("fs_min") is not None), key=lambda s: s["fs_min"]
|
||||
)
|
||||
picked = [{"station_key": s["station_key"], "reason": "lowest_fs"} for s in ranked][
|
||||
:LOWEST_N
|
||||
]
|
||||
return [
|
||||
{**p, "summary": by_key[p["station_key"]]} for p in picked if p["station_key"] in by_key
|
||||
]
|
||||
def _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 = _read(project_root / RESULT2D)
|
||||
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, _read(project_root / REPRESENTATIVE))
|
||||
reps = _representatives(r2)
|
||||
for item in reps:
|
||||
chainage = float(item["summary"]["chainage_m"])
|
||||
item["result"] = _station_result(project_root, item["station_key"])
|
||||
item["result"] = _station_result(project_root, item["summary"]["fp"])
|
||||
try:
|
||||
item["cross"] = source.cross_section(root, chainage)
|
||||
except KeyError:
|
||||
@@ -426,7 +461,7 @@ def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[s
|
||||
f"대표 단면 {mark} — {label} · {SIDE_NAMES.get(side, side or '-')} · H {height:.1f} m",
|
||||
)
|
||||
reason = REASONS.get(rep.get("reason"), rep.get("reason") or "")
|
||||
if rep.get("reason") == "lowest_fs":
|
||||
if rep.get("reason") == "lowest":
|
||||
reason = f"{reason} {LOWEST_N}"
|
||||
_set(sheet, f"B{2 + dr}", reason)
|
||||
key = fig_key(rep["station_key"])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""534-3(빌더 · storage · 길) — E05 안정성평가 보고서 `E05_StabReport_Build` · `_Store` · `_Router`.
|
||||
|
||||
가짜 E 프로젝트(2D 요약 · 측점별 저장본 · 대표 단면 · 지반 조건 · 3D 저장본 하나 ·
|
||||
가짜 E 프로젝트(E03 파생 저장본 꼴 2D 요약 · 측점별 저장본 · 대표 단면 켬/끔 · 지반 조건 · 3D 저장본 하나 ·
|
||||
복사본 읽기는 바꿔 끼움)로 측점 수 = 줄 수 · N.G 수 · 그림 파일 수 · 갱신 뒤 사용자 몫 불변 ·
|
||||
덮임 목록 · 초기화 · 판 409 · xlsx 되읽기 · PDF 쪽 수.
|
||||
"""
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
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
|
||||
@@ -24,7 +25,11 @@ from E05_StabReport import E05_StabReport_Store as store
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
N = 30 # 측점 수
|
||||
NG_AT = {3, 10, 17, 24} # N.G 측점
|
||||
REPS = [("grid:3", "lowest_fs"), ("grid:12", "cut_15m"), ("grid:20", "fill_10m")]
|
||||
REPS = [("grid:3", "lowest"), ("grid:12", "cut_height"), ("grid:20", "fill_height")]
|
||||
# 자동 대표(높이 · 하위 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"
|
||||
REQUIRED = {"dry": {"cut": 1.5, "fill": 1.5}, "wet": {"cut": 1.2, "fill": 1.3}}
|
||||
|
||||
|
||||
def _key(i: int) -> str:
|
||||
@@ -32,17 +37,49 @@ def _key(i: int) -> str:
|
||||
|
||||
|
||||
def _summary(i: int, bump: float = 0.0) -> dict:
|
||||
ng = i in NG_AT
|
||||
"""E03 요약 줄 꼴 — 지배 비탈(홀수 = 깎기 왼쪽 · 짝수 = 쌓기 오른쪽)만 낮은 안전율."""
|
||||
ng = i in NG_AT and not bump
|
||||
dry = 1.31 if ng else 1.7 + i / 100 + bump
|
||||
return {
|
||||
"station_key": _key(i),
|
||||
"chainage_m": 20.0 * i,
|
||||
"side": "cut" if i % 2 else "fill",
|
||||
"fs_min": dry - 0.2,
|
||||
"fs_by_condition": {"dry": dry, "wet": dry - 0.2},
|
||||
"method": "bishop",
|
||||
"verdict": "ng" if ng else "ok",
|
||||
role = "cut" if i % 2 else "fill"
|
||||
side = "left" if role == "cut" else "right"
|
||||
other = "right" if side == "left" else "left"
|
||||
heights = {"cut": 15.0 if i == 12 else 6.0, "fill": 10.0 if i == 20 else 6.0}
|
||||
fs = {"dry": dry, "wet": dry - 0.2}
|
||||
cases = {
|
||||
cond: {
|
||||
side: {"fs": fs[cond], "required": REQUIRED[cond][role]},
|
||||
other: {"fs": 9.0, "required": REQUIRED[cond]["fill" if role == "cut" else "cut"]},
|
||||
}
|
||||
for cond in fs
|
||||
}
|
||||
return {
|
||||
"key": _key(i),
|
||||
"chainage_m": 20.0 * i,
|
||||
"label": f"No.{i}",
|
||||
"fp": f"st{i}",
|
||||
"sides": [
|
||||
{"side": "left", "role": "cut", "height_m": heights["cut"]},
|
||||
{"side": "right", "role": "fill", "height_m": heights["fill"]},
|
||||
],
|
||||
"cases": cases,
|
||||
"min": {c: min(v["fs"] for v in cases[c].values()) for c in cases},
|
||||
"verdict": {
|
||||
c: "ok" if all(v["fs"] >= v["required"] for v in cases[c].values()) else "ng"
|
||||
for c in cases
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _write_summary(root: Path, fp: str, rows: list[dict]) -> None:
|
||||
value = {
|
||||
"fingerprint": fp,
|
||||
"computed_at": "2026-10-25T10:00:00",
|
||||
"conditions": ["dry", "wet"],
|
||||
"method": "bishop",
|
||||
"stations": rows,
|
||||
}
|
||||
write_derived(root, KIND_SUMMARY, fp, value)
|
||||
|
||||
|
||||
def _slip(F: float) -> dict:
|
||||
@@ -54,7 +91,7 @@ def _station_doc(i: int) -> dict:
|
||||
s = _summary(i)
|
||||
cases = []
|
||||
for cond in ("dry", "wet"):
|
||||
F = s["fs_by_condition"][cond]
|
||||
F = s["min"][cond]
|
||||
for side, role in (("left", "cut"), ("right", "fill")):
|
||||
cases.append(
|
||||
{
|
||||
@@ -94,32 +131,13 @@ def _cross(chainage: float) -> dict:
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch) -> Path:
|
||||
root = tmp_path / "e_project"
|
||||
d2 = root / "E03_StabSection" / "_derived"
|
||||
(d2 / "results").mkdir(parents=True)
|
||||
(d2 / "result2d.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_fingerprint": "fp-1",
|
||||
"computed_at": "2026-10-25T10:00:00",
|
||||
"stations": [_summary(i) for i in range(N)],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_summary(root, "fp-1", [_summary(i) for i in range(N)])
|
||||
for key, _ in REPS:
|
||||
i = int(key.split(":")[1])
|
||||
(d2 / "results" / f"grid_{i}.json").write_text(
|
||||
json.dumps(_station_doc(i)), encoding="utf-8"
|
||||
)
|
||||
write_derived(root, KIND_STATION, f"st{i}", {"out": _station_doc(i), "error": None}, keep=N)
|
||||
(root / "E03_StabSection").mkdir()
|
||||
(root / "E03_StabSection" / "representative.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"rule": {"fill_h_m": 10, "cut_h_m": 15, "lowest_n": 5},
|
||||
"stations": [{"station_key": k, "reason": r, "enabled": True} for k, r in REPS]
|
||||
+ [{"station_key": _key(5), "reason": "user", "enabled": False}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
json.dumps({"overrides": OFF}), encoding="utf-8"
|
||||
)
|
||||
ground = ground_model.pick(ground_model.load_defaults())
|
||||
ground["stations"] = [
|
||||
@@ -216,11 +234,9 @@ def test_갱신_사용자몫_불변_덮임(project):
|
||||
by = _sheets(book)
|
||||
by["측점표"]["칸"]["A6"]["값"] = "고친 측점"
|
||||
store.save(project, book)
|
||||
r2 = project / build.RESULT2D
|
||||
doc = json.loads(r2.read_text(encoding="utf-8"))
|
||||
doc["stations"][3] = _summary(4, 0.5) | {"station_key": _key(3), "chainage_m": 60.0}
|
||||
doc["input_fingerprint"] = "fp-2"
|
||||
r2.write_text(json.dumps(doc), encoding="utf-8")
|
||||
rows = [_summary(i) for i in range(N)]
|
||||
rows[3] = _summary(3, 0.5)
|
||||
_write_summary(project, "fp-2", rows)
|
||||
assert build.analysis_fingerprint(project) != store.load(project)[1]["fingerprint"]
|
||||
try:
|
||||
out = store.refresh(project)
|
||||
@@ -251,7 +267,7 @@ def test_3D_없음_2D_없음(project):
|
||||
by = _sheets(book)
|
||||
assert by["3D요약"]["칸"]["A20"]["값"] == "3D 해석 전"
|
||||
assert by["입력조건"]["칸"]["B7"]["값"] == "3D 해석 전"
|
||||
(project / build.RESULT2D).unlink()
|
||||
shutil.rmtree(derived_dir(project, KIND_SUMMARY))
|
||||
with pytest.raises(build.MissingInput):
|
||||
build.gather(project)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user