fix(532-5): E03 → E05 이음매 하나로 — 사본 쓰기 걷음 · result2d 지배 비탈 덧칸(fs / 기준)

- E03 export_report · report_row · report_representatives · REPORT_REASONS 걷음 — E05 는 E03 정본(stab2d_summary · stab2d_station)만 읽음 · representative.json = overrides 만
- GET …/stab/result2d 측점 줄에 station_key · side · fs_min · fs_by_condition · method · verdict_overall 덧붙임(E04 2D 최소) — 지배 = 조건 × 비탈 중 fs / 기준 가장 낮은 것 · 조건별 verdict 칸은 E03 화면 몫이라 그대로
- 시험 test_532_5 — 옛 사본 꼴 시험을 E05 = E03 정본(측점 수 · N.G 수 · 지배 비탈 같음)으로 바꿈 · 지배 비탈 규칙(깎기 1.22/1.2 · 쌓기 1.25/1.3 → 쌓기 N.G · E05 _row2d 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 23:22:25 +09:00
co-authored by Claude Opus 5.5
parent 58641f1a5f
commit 0ac8bc0d9f
3 changed files with 81 additions and 75 deletions
+4 -37
View File
@@ -2,7 +2,7 @@
- `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈
- `GET …/stab/progress` — 진행률
- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수) + 횡단 없는 측점
- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수 · 지배 비탈) + 횡단 없는 측점
- `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자
- `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json`
- `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용)
@@ -64,8 +64,6 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
KIND_STATION = worker.KIND_STATION
KIND_SUMMARY = "stab2d_summary"
REPRESENTATIVE = Path("E03_StabSection") / "representative.json"
#: E05 보고서가 읽는 꼴(`E05_StabReport_Build` · result2d.json · results/<측점>.json)
REPORT_DIR = Path("E03_StabSection") / "_derived"
ENGINE_FILES = (
"E03_StabSection_Types.py",
"E03_StabSection_Engine_Model.py",
@@ -280,38 +278,9 @@ def run_all(root: Path, plan: dict[str, Any], report: Any) -> int:
"stations": rows,
}
write_derived(root, KIND_SUMMARY, plan["fp"], summary)
export_report(root, summary)
return len(todo)
def export_report(root: Path, summary: dict[str, Any]) -> None:
"""E05 보고서 입력 — 요약(측점 줄) · 대표 단면 측점 해석 결과 · representative.json `stations`
(E03 은 `overrides` 만 읽음). [해석] 끝 · [대표 단면 저장] 때 씀."""
rows = [dict(r) for r in summary["stations"]]
overrides = _overrides(root)
view.mark_representative(rows, overrides)
folder = root / REPORT_DIR
atomic_write_json(
folder / "result2d.json",
{
"input_fingerprint": summary["fingerprint"],
"computed_at": summary["computed_at"],
"conditions": summary["conditions"],
"stations": [view.report_row(r) for r in rows],
},
)
reps = view.report_representatives(rows)
results = folder / "results"
for old in results.glob("*.json"):
old.unlink()
by_key = {r["key"]: r for r in rows}
for rep in reps:
saved = read_derived(root, KIND_STATION, by_key[rep["station_key"]]["fp"])
if saved and saved["value"]["out"]:
atomic_write_json(results / f"{rep['station_key']}.json", saved["value"]["out"])
atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides, "stations": reps})
def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]:
"""(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과)."""
saved = read_derived(root, KIND_SUMMARY, fp)
@@ -392,6 +361,7 @@ async def result2d(
if not summary:
rows = [view.station_row(_meta(item, {}), None, plan["factors"]) for item in plan["items"]]
view.mark_representative(rows, _overrides(root))
rows = [{**r, **view.governing_row(r)} for r in rows]
return {
"status": "none",
"fresh": False,
@@ -404,6 +374,7 @@ async def result2d(
}
rows = summary["stations"]
view.mark_representative(rows, _overrides(root))
rows = [{**r, **view.governing_row(r)} for r in rows]
return {
"status": "ok",
"fresh": fresh,
@@ -455,16 +426,12 @@ async def section(project_id: UUID, station_key: str) -> dict[str, Any]:
async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]:
"""대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만."""
root, _ = await e02._project(project_id)
plan = plan_run(root, _ground(root))
known = {item["key"] for item in plan["items"]}
known = {item["key"] for item in plan_run(root, _ground(root))["items"]}
unknown = sorted(set(payload.overrides) - known)
if unknown:
raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}")
overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None}
atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides})
summary, _ = _latest_summary(root, plan["fp"])
if summary:
export_report(root, summary)
return {"overrides": overrides}
+23 -31
View File
@@ -130,44 +130,36 @@ def mark_representative(rows: list[dict[str, Any]], overrides: dict[str, bool])
}
#: 대표 단면 까닭 → E05 보고서 열쇠(`E05_StabReport_Build.REASONS`)
REPORT_REASONS = {"fill_height": "fill_10m", "cut_height": "cut_15m", "lowest": "lowest_fs"}
def report_row(row: dict[str, Any]) -> dict[str, Any]:
"""E05 보고서가 읽는 측점 줄 — 지배 비탈(전 조건 최소 안전율 쪽) 역할 · 조건별 최소 · 판정."""
worst = None
for by_side in row["cases"].values():
for side, value in by_side.items():
if value["fs"] is not None and (worst is None or value["fs"] < worst[0]):
worst = (value["fs"], side)
roles = {s["side"]: s["role"] for s in row["sides"]}
values = [v for v in row["min"].values() if v is not None]
def governing_row(row: dict[str, Any]) -> dict[str, Any]:
"""측점 줄 덧칸(E04 · E05 같은 규칙) — 지배 = 조건 × 비탈 중 기준 대비 가장 낮은 것(fs / 기준) ·
fs_min = 그 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, c["fs"])
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"],
"label": row.get("label"),
"side": roles.get(worst[1]) if worst else None,
"fs_min": min(values) if values else None,
"fs_by_condition": dict(row["min"]),
"side": roles.get(side),
"fs_min": worst[2] if worst else min(values, default=None),
"fs_by_condition": {
cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides
},
"method": PRIMARY,
"verdict": overall_verdict(row),
"walls": row.get("walls") or 0,
"verdict_overall": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None,
}
def report_representatives(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""E05 대표 단면 목록(켠 것만) — `mark_representative` 뒤 행."""
out = []
for row in rows:
rep = row.get("representative") or {}
if rep.get("on"):
auto = rep.get("auto") or []
reason = "user" if rep.get("user") or not auto else REPORT_REASONS.get(auto[0], auto[0])
out.append({"station_key": row["key"], "reason": reason, "enabled": True})
return out
def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, Any]:
"""머리 요약 — 조건별 최소 안전율 · N.G 측점 수."""
out: dict[str, Any] = {"stations": len(rows), "errors": sum(1 for r in rows if r["error"])}
+54 -7
View File
@@ -5,6 +5,8 @@
측점 보기에 벽 폴리곤 · 목록 벽 수 · 벽 있는 측점만 지문이 바뀜
- 지하수: E02 우기 갈래를 바꾸면 측점 지문이 바뀜 · 측점 보기 수위선 = 고른 갈래
- 빠진 횡단: 횡단 파일이 없으면 result2d · stab/ground 의 missing_stations 에 그 측점
- E05 이음매: E05 가 E03 정본만 읽음(사본 없음) · 측점 수 · N.G 수 · 지배 비탈이 E03 과 같음
- result2d 덧칸: 지배 비탈 = fs / 기준 가장 낮은 쪽(깎기 1.22/1.2 · 쌓기 1.25/1.3 → 쌓기 · N.G)
./venv/Scripts/python.exe -m pytest resources/tester/test_532_5_e03_seam.py -q
"""
@@ -32,6 +34,7 @@ from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
from E02_StabGround import E02_StabGround_Model as ground_model # noqa: E402
from E02_StabGround import E02_StabGround_Router as e02 # noqa: E402
from E03_StabSection import E03_StabSection_Router as router # noqa: E402
from E03_StabSection import E03_StabSection_View as view # noqa: E402
from E03_StabSection import E03_StabSection_Worker as worker # noqa: E402
from E03_StabSection.E03_StabSection_Engine_Station import analyze_station # noqa: E402
@@ -190,27 +193,71 @@ def test_빠진_횡단_목록(project):
assert asyncio.run(router.result2d(UUID(PID), USER))["missing_stations"] == []
def test_E05_가_E03_저장본으로_보고서(project):
"""E03 [해석] 이 쓴 E05 꼴(result2d.json · results/ · representative stations)로 E05 가 돎."""
def test_E05_가_E03_정본으로_보고서(project):
"""E03 [해석] 정본(stab2d_summary · stab2d_station)으로 E05 가 돎 — 사본 안 씀 · 수가 같음."""
from E05_StabReport import E05_StabReport_Build as build
_analyze()
assert not (project / "E03_StabSection" / "_derived").exists() # 옛 사본 꼴 없음
body = asyncio.run(router.result2d(UUID(PID), USER))
inputs = build.gather(project)
rows = inputs["result2d"]["stations"]
assert len(rows) == 2 and WALL_KEY in {r["station_key"] for r in rows}
assert len(rows) == len(body["stations"]) == 2
assert WALL_KEY in {r["station_key"] for r in rows}
ng = sum(1 for r in body["stations"] if r["verdict_overall"] == "ng")
assert sum(1 for r in rows if r["verdict"] == "ng") == ng
e03 = {r["station_key"]: r for r in body["stations"]}
for r in rows:
assert r["side"] in ("cut", "fill") and r["verdict"] in ("ok", "ng")
assert r["fs_min"] == min(v for v in r["fs_by_condition"].values() if v is not None)
mine = e03[r["station_key"]]
assert r["side"] == mine["side"] in ("cut", "fill")
assert r["fs_by_condition"] == mine["fs_by_condition"]
assert r["verdict"] == mine["verdict_overall"] in ("ok", "ng")
reps = inputs["representatives"]
assert reps and all(rep["result"] and rep["result"]["cases"] for rep in reps)
book = build.form()
jobs = build.build_program(book, inputs)
assert len([k for k, svg in jobs if svg.startswith("<svg")]) >= len(reps)
# 대표 단면 끔 → E05 목록에서 빠짐 · E03 overrides 는 그대로 읽힘
# 대표 단면 끔 → E05 목록에서 빠짐 · representative.json = overrides 만
key = reps[0]["station_key"]
asyncio.run(
router.put_representative(UUID(PID), router.RepresentativeRequest(overrides={key: False}))
)
assert key not in [r["station_key"] for r in build.gather(project)["representatives"]]
assert router._overrides(project) == {key: False}
saved = json.loads((project / router.REPRESENTATIVE).read_text("utf-8"))
assert saved == {"overrides": {key: False}}
def _case(fs, need):
return {"fs": fs, "required": need, "ok": fs >= need}
def test_result2d_지배_비탈_기준_대비():
"""깎기 1.22/1.2(O.K) · 쌓기 1.25/1.3(N.G) — fs 만 낮은 깎기가 아니라 쌓기가 지배 · N.G."""
from E05_StabReport import E05_StabReport_Build as build
row = {
"key": "10.00",
"chainage_m": 10.0,
"sides": [{"side": "left", "role": "cut"}, {"side": "right", "role": "fill"}],
"cases": {
"dry": {"left": _case(1.6, 1.5), "right": _case(1.7, 1.5)},
"wet": {"left": _case(1.22, 1.2), "right": _case(1.25, 1.3)},
},
"min": {"dry": 1.6, "wet": 1.22},
"verdict": {"dry": "ok", "wet": "ng"},
}
out = view.governing_row(row)
assert out["side"] == "fill" and out["fs_min"] == 1.25
assert out["fs_by_condition"] == {"dry": 1.7, "wet": 1.25}
assert out["verdict_overall"] == "ng" and out["method"] == "bishop"
e05 = build._row2d(row, "bishop")
assert (e05["side"], e05["fs_by_condition"], e05["verdict"]) == (
out["side"],
out["fs_by_condition"],
out["verdict_overall"],
)
row["verdict"] = {"dry": "ok", "wet": "ok"}
assert view.governing_row(row)["verdict_overall"] == "ok"
bare = {**row, "cases": {"dry": {"left": {"fs": 1.4, "required": None}}}}
assert view.governing_row(bare)["side"] is None and view.governing_row(bare)["fs_min"] == 1.22