- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
228 lines
16 KiB
Python
228 lines
16 KiB
Python
"""Stage C (part 1) test of the 입력 묶음 확인·확정 개편 (2026-10-19), no Rhino: anchors (engine/anchors.py) learned from one modelled pier and followed
|
|
into the drawing of another, the web screen, the apply rules and the effect on the app check. Own data root 검증/anchors_test_data (recreated).
|
|
Acceptance (기획서 4.2 / 6장): grade A has no wrong candidate in the same series, a drawing frame that differs gets no A / B candidate (only C), a
|
|
candidate is never invented where nothing is found, the R2 chain check finds the known drawing error, and the app never applies a candidate by itself.
|
|
"""
|
|
import itertools
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
APP = Path(__file__).resolve().parents[1]
|
|
TD = APP / "검증" / "anchors_test_data"
|
|
if TD.exists():
|
|
shutil.rmtree(TD)
|
|
D = APP / "data"
|
|
names = ["산외교울산P1", "산외교울산P2", "운곡교P1", "운곡교p2", "운곡교P3", "운곡교P4", "새재1교P1", "옥성교P1", "Ramp-A교P1"]
|
|
SERIES = {"산외교울산P1": "U", "산외교울산P2": "U", "운곡교P1": "G", "운곡교p2": "G", "운곡교P3": "G", "운곡교P4": "G", "새재1교P1": "S", "옥성교P1": "O", "Ramp-A교P1": "R"}
|
|
sys.path.insert(0, str(APP))
|
|
sys.path.insert(0, str(APP / "web"))
|
|
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
|
|
os.environ["PIERAPP_DATA"] = str(TD) # BEFORE the first import of store / app (store reads DATA at import time)
|
|
import app as webapp # noqa: E402
|
|
import store # noqa: E402
|
|
from engine import anchors # noqa: E402
|
|
|
|
ok = True
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
global ok
|
|
ok &= bool(cond)
|
|
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
|
|
|
|
|
|
def src(n):
|
|
"""data/<n>, or the newest copy in data/_휴지통 when the pier was deleted in real use (운곡교p2 was, 2026-10-19); read only."""
|
|
d = D / n
|
|
if (d / "params.json").exists():
|
|
return d
|
|
trash = sorted(t for t in (D / "_휴지통").glob(f"{n}_*") if t.name.startswith(n + "_")) # exact case: Windows globbing is case-insensitive (운곡교p2 / 운곡교P2)
|
|
return trash[-1] if trash else d
|
|
|
|
|
|
def load(n):
|
|
return [json.loads((src(n) / f).read_text(encoding="utf-8")) for f in ("params.json", "expected.json", "drawings/texts.json")]
|
|
|
|
|
|
def get(docs, field):
|
|
return store.get_field(docs, field)
|
|
|
|
|
|
L = {n: load(n) for n in names}
|
|
|
|
# ---- 1. same series: grade A has no wrong candidate; B is mostly right; C is weak and labelled so
|
|
st = {g: [0, 0] for g in "ABC"}
|
|
recovered = 0
|
|
for a, b in [("산외교울산P1", "산외교울산P2"), ("산외교울산P2", "산외교울산P1"), ("운곡교P1", "운곡교p2"), ("운곡교p2", "운곡교P1")]:
|
|
Pa, Ea, Ta = L[a]
|
|
Pb, Eb, Tb = L[b]
|
|
anc = anchors.learn(Pa, Ea, Ta)
|
|
cand = anchors.follow(anc, Tb)
|
|
for f, c in cand.items():
|
|
tv = get({"params": Pb, "expected": Eb}, f)
|
|
if tv is None:
|
|
continue
|
|
good = abs(abs(tv) - abs(c["value"])) < 1.5
|
|
st[c["grade"]][0] += 1
|
|
st[c["grade"]][1] += good
|
|
recovered += c["grade"] == "A" and good and abs(abs(c["source_value"]) - abs(tv)) > 1.5
|
|
check(f"{a} -> {b}: frame coverage >= 70 %", next(iter(cand.values()))["coverage"] >= 0.7, next(iter(cand.values()))["coverage"])
|
|
check(f"same series: grade A all right ({st['A'][1]}/{st['A'][0]}), at least 30 A candidates", st["A"][0] >= 30 and st["A"][1] == st["A"][0], st["A"])
|
|
check(f"same series: changed values recovered by A candidates ({recovered}) >= 10", recovered >= 10)
|
|
check(f"same series: grade B right in >= 80 % ({st['B'][1]}/{st['B'][0]})", st["B"][0] and st["B"][1] / st["B"][0] >= 0.8, st["B"])
|
|
print("INFO grade C right", st["C"], "(shown to the user as weak, needs a reason to apply)")
|
|
|
|
# ---- 1b. table cross-check: a field with a table value is confirmed (A) or corrected (C) by it
|
|
import copy # noqa: E402
|
|
from engine import reader # noqa: E402
|
|
st2 = {g: [0, 0] for g in "ABC"}
|
|
n_conf = n_low = 0
|
|
for a, b in [("산외교울산P1", "산외교울산P2"), ("산외교울산P2", "산외교울산P1"), ("운곡교P1", "운곡교p2"), ("운곡교p2", "운곡교P1")]:
|
|
Pa, Ea, Ta = L[a]
|
|
Pb, Eb, Tb = L[b]
|
|
cand = anchors.follow(anchors.learn(Pa, Ea, Ta), Tb)
|
|
tab = anchors.table_values(reader.proposals(reader.field_candidates(reader.table_candidates(Tb)), Pb, Eb))
|
|
after = anchors.cross_check(copy.deepcopy(cand), tab)
|
|
n_conf += sum(1 for c in after.values() if c.get("table_confirmed"))
|
|
n_low += sum(1 for c in after.values() if c.get("table_confirmed") is False)
|
|
for f, c in after.items():
|
|
tv = get({"params": Pb, "expected": Eb}, f)
|
|
if tv is None:
|
|
continue
|
|
st2[c["grade"]][0] += 1
|
|
st2[c["grade"]][1] += abs(abs(tv) - abs(c["value"])) < 1.5
|
|
check(f"table cross-check: grade A is still all right ({st2['A'][1]}/{st2['A'][0]}) and larger than before ({st['A'][0]})", st2["A"][0] > st["A"][0] and st2["A"][1] == st2["A"][0], st2["A"])
|
|
check(f"table cross-check: {n_conf} candidates confirmed by a table value, {n_low} lowered to C by a different one", n_conf >= 100)
|
|
|
|
# ---- 2. a different drawing frame: no A / B candidate, nothing invented
|
|
bad_frames = []
|
|
for a, b in itertools.permutations(names, 2):
|
|
if SERIES[a] == SERIES[b]:
|
|
continue
|
|
anc = anchors.learn(*L[a][:2], L[a][2])
|
|
cand = anchors.follow(anc, L[b][2])
|
|
n_ab = sum(1 for c in cand.values() if c["grade"] in ("A", "B"))
|
|
if n_ab:
|
|
bad_frames.append((a, b, n_ab))
|
|
check("different series (frame coverage < 70 %): no A or B candidate in any of the other-series pairs", not bad_frames, bad_frames[:3])
|
|
anc = anchors.learn(*L["Ramp-A교P1"][:2], L["Ramp-A교P1"][2])
|
|
check("a drawing with none of the anchors' texts gives no candidate at all", not anchors.follow(anc, L["운곡교P1"][2]) or all(c["grade"] == "C" for c in anchors.follow(anc, L["운곡교P1"][2]).values()))
|
|
assert Path(str(store.DATA)) == TD, store.DATA # the test never touches data/
|
|
check("anchors skip values below 1 m and fields of F / V3 bundles", all(abs(a["v"]) >= 1000 for a in anchors.learn(*L["옥성교P1"][:2], L["옥성교P1"][2])["fields"].values())
|
|
and not any(f.startswith(("params:coping_rebar", "params:footing_rebar", "params:scope")) for f in anchors.learn(*L["옥성교P1"][:2], L["옥성교P1"][2])["fields"]))
|
|
|
|
# ---- 3. R2 chain check: the known error of the Ungok P4 drawing (62@125=7.250 and 29@250=7.750 are swapped totals), no false alarm elsewhere
|
|
bad = {n: [c["text"] for c in anchors.chains(L[n][2]) if not c["ok"]] for n in names}
|
|
check("R2: Ungok P4 drawing has the two inconsistent chains", sorted(bad["운곡교P4"]) == sorted(["62@125=7.250", "29@250=7.750"]), bad["운곡교P4"])
|
|
check("R2: no inconsistent chain in the other 8 drawings", all(not v for n, v in bad.items() if n != "운곡교P4"), {n: v for n, v in bad.items() if v and n != "운곡교P4"})
|
|
|
|
# ---- 4. web flow: a new Ungok pier (template 운곡교P1형) with the drawing index of Ungok P2 (a different pier of the same series)
|
|
for n in ("운곡교p2",):
|
|
shutil.copytree(src(n), TD / "_src" / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "confirm.json"))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from testlib import confirm_required # noqa: E402
|
|
|
|
c = webapp.app.test_client()
|
|
c.post("/pier/new", data={"name": "앵커시험", "type": "T형", "template": "tpl:운곡교P1형"})
|
|
p = store.Pier("앵커시험")
|
|
check("new pier carries the template's anchors", (p.dir / "anchors.json").exists() and not p.texts())
|
|
rows, miss, meta = p.anchor_rows()
|
|
check("no drawing yet -> no candidates (nothing invented)", rows == [] and meta["texts"] is False)
|
|
shutil.copytree(TD / "_src" / "운곡교p2" / "drawings", p.drawings, dirs_exist_ok=True) # "upload": index + converted DXF + audit of Ungok P2
|
|
aud = store.rjson(p.drawings / "audit.json", [])
|
|
for a in aud:
|
|
a["source_sha256"] = "x" + a.get("source_sha256", "")[1:] # a different file than the template's source drawing
|
|
store.wjson(p.drawings / "audit.json", aud)
|
|
rows, miss, meta = p.anchor_rows()
|
|
tr = store.Pier("운곡교p2") if False else None
|
|
P2 = json.loads((src("운곡교p2") / "params.json").read_text(encoding="utf-8"))
|
|
E2 = json.loads((src("운곡교p2") / "expected.json").read_text(encoding="utf-8"))
|
|
diff_a = [r for r in rows if r["grade"] == "A" and not r["same"]]
|
|
check(f"new drawing: coverage >= 70 %, {len(rows)} candidates, {len(diff_a)} A candidates differ from the template's values", meta["coverage"] >= 0.7 and diff_a, meta)
|
|
check("every differing A candidate equals the value of the modelled pier 운곡교p2 (no wrong A)", all(abs(abs(store.get_field({"params": P2, "expected": E2}, r["field"]) or 0) - abs(r["value"])) < 1.5
|
|
for r in diff_a), [r["field"] for r in diff_a if abs(abs(store.get_field({"params": P2, "expected": E2}, r["field"]) or 0) - abs(r["value"])) >= 1.5])
|
|
html = c.get("/pier/앵커시험/candidates").get_data(as_text=True)
|
|
check("candidates page lists the out-of-table candidates with grades, positions and the view link", "표 밖 치수 후보" in html and "등급 A" in html and "도면 보기" in html and "도면 틀 일치율" in html)
|
|
check("candidate page offers the one-file quick selection of grade A rows and shows the table confirmation", 'data-sel-a="expected"' in html and 'data-sel-a="params"' in html and "표 값과도 일치" in html)
|
|
check("the app applied nothing by itself", p.load("params") == json.loads((D / "_템플릿" / "운곡교P1형" / "params.json").read_text(encoding="utf-8")))
|
|
|
|
# ---- 5. app check: the A candidates that differ make the bundle 불일치; applying them makes it agree
|
|
a0 = p.run_autocheck(force=True)["sections"]
|
|
bundles = sorted({r["field"].rsplit(".", 0)[0].split(".")[0] for r in diff_a})
|
|
hit = [b for b in bundles if a0[b]["st"] == "b" and any(x["id"] == "C2b" and x["st"] == "b" for x in a0[b]["checks"])]
|
|
check("bundles with a differing A candidate are 불일치 by C2b", set(hit) == set(bundles) and hit, {b: a0[b]["st"] for b in bundles})
|
|
|
|
# ---- 6. apply rules
|
|
pa = [r for r in diff_a if r["file"] == "params"]
|
|
ex = [r for r in diff_a if r["file"] == "expected"]
|
|
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": []}, follow_redirects=True)
|
|
check("applying nothing is refused", "반영할 후보를 고르세요" in r0.get_data(as_text=True))
|
|
same_rows = [r for r in rows if r["same"]]
|
|
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [same_rows[0]["field"]]}, follow_redirects=True)
|
|
check("a candidate equal to the current value is not applied", "반영할 후보를 고르세요" in r0.get_data(as_text=True))
|
|
twin = next(((a, b) for a in pa for b in ex if (a["text"], a["x"], a["y"], a["value"]) == (b["text"], b["x"], b["y"], b["value"])), None)
|
|
if twin:
|
|
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [twin[0]["field"], twin[1]["field"]]}, follow_redirects=True)
|
|
check("the same drawing text into params and expected together is refused (P7)", "함께 반영할 수 없습니다" in r0.get_data(as_text=True))
|
|
weak = next((r for r in rows if r["grade"] in ("B", "C") and not r["same"] and r["grade"] == "C"), None)
|
|
if weak:
|
|
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [weak["field"]]}, follow_redirects=True)
|
|
check("a grade C candidate needs a reason", "사유를 입력" in r0.get_data(as_text=True))
|
|
first = pa[0]
|
|
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [first["field"]]}, follow_redirects=True)
|
|
check("applying a differing A candidate writes the value and a log entry", abs(store.get_field({"params": p.load("params"), "expected": p.load("expected")}, first["field"]) - first["value"]) < 1e-6
|
|
and store.rjson(p.dir / "anchor_log.json", [])[-1]["fields"][0]["field"] == first["field"])
|
|
twin_b = next((b for b in ex if (b["text"], b["x"], b["y"]) == (first["text"], first["x"], first["y"])), None)
|
|
if twin_b:
|
|
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [twin_b["field"]]}, follow_redirects=True)
|
|
check("the same text may not be applied to expected later either (P7 across requests)", "함께 반영할 수 없습니다" in r0.get_data(as_text=True))
|
|
check("the applied bundle needs a new confirmation", not p.section_state(first["field"].split(":")[0], first["field"].split(":")[1].split(".")[0])["confirmed"])
|
|
|
|
# ---- 7. a pier without anchors can load them from a template; own-drawing anchors are no evidence
|
|
q = store.Pier("앵커시험")
|
|
c.post("/pier/new", data={"name": "복사시험", "type": "T형", "template": "tpl:산외교울산P2형"})
|
|
q2 = store.Pier("복사시험")
|
|
os.remove(q2.dir / "anchors.json")
|
|
r0 = c.post("/pier/복사시험/anchors/load", data={"template": "산외교울산P1형"}, follow_redirects=True)
|
|
check("anchors can be loaded from a template into a pier without anchors", (q2.dir / "anchors.json").exists() and "앵커를 불러왔습니다" in r0.get_data(as_text=True))
|
|
shutil.copytree(D / "산외교울산P2" / "drawings", q2.drawings, dirs_exist_ok=True, ignore=shutil.ignore_patterns("dwg", "dxf"))
|
|
os.remove(q2.dir / "anchors.json")
|
|
shutil.copy2(D / "_템플릿" / "산외교울산P2형" / "anchors.json", q2.dir / "anchors.json") # anchors learned from this very drawing
|
|
aud = store.rjson(q2.drawings / "audit.json", [])
|
|
srcs = {a["source_sha256"] for a in aud}
|
|
anc2 = store.rjson(q2.dir / "anchors.json")
|
|
check("template anchors remember the source drawing", set(anc2["sources"]) == srcs, anc2["sources"][:1])
|
|
r2, _m, meta2 = q2.anchor_rows()
|
|
check("own-drawing anchors are flagged and give no evidence to the app check", meta2["self_source"] and not any(
|
|
x["id"] == "C2b" for r in q2.run_autocheck(force=True)["sections"].values() for x in r["checks"]))
|
|
|
|
# ---- 8. the region image
|
|
ent = aud[0]
|
|
shutil.copytree(D / "산외교울산P2" / "drawings" / "dxf", q2.drawings / "dxf", dirs_exist_ok=True)
|
|
r0 = c.get(f"/pier/복사시험/view/{ent['label']}?x=-85062&y=-14500&half=3000")
|
|
check("region image route returns a PNG", r0.status_code == 200 and r0.data[:4] == b"\x89PNG" and len(r0.data) > 5000, (r0.status_code, len(r0.data)))
|
|
check("bad view arguments are refused", c.get("/pier/복사시험/view/D1?x=abc&y=1").status_code == 400 and c.get("/pier/복사시험/view/D9?x=1&y=1").status_code == 404)
|
|
|
|
# ---- 9. pier page notice for the drawing's own chain errors
|
|
shutil.copytree(D / "운곡교P4", TD / "운곡교P4", ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf"))
|
|
h4 = c.get("/pier/운곡교P4").get_data(as_text=True)
|
|
check("pier page warns about the inconsistent drawing chains", "치수 사슬 2건이 검산에 맞지 않습니다" in h4 and "62@125=7.250" in h4)
|
|
|
|
# ---- 10. improvement #34-4 (2026-10-20): a candidate whose only followed instance is ONE schedule cell is demoted to C (Ungok P3 / P4: the S1 length cell
|
|
# '3.800' was followed as the end of a rebar chain and graded B)
|
|
anc1 = anchors.learn(*L["운곡교P1"][:2], L["운곡교P1"][2])
|
|
for tgt in ("운곡교P3", "운곡교P4"):
|
|
cand = anchors.follow(anc1, L[tgt][2])
|
|
lone = {f: c for f, c in cand.items() if c["followed"] == 1 and "TABL" in c["layer"]}
|
|
check(f"{tgt}: {len(lone)} candidates rest on one table cell and none is graded A or B", lone and all(c["grade"] == "C" and c["table_only"] for c in lone.values()),
|
|
[(f, c["grade"]) for f, c in lone.items() if c["grade"] != "C"][:3])
|
|
check(f"{tgt}: the false chain-end candidate G3.rows.X_250.ends is not offered above C",
|
|
all(cand[f]["grade"] == "C" for f in cand if f.startswith("expected:G3.rows.X_250.ends")))
|
|
others = [c for c in cand.values() if "TABL" not in c["layer"] or c["followed"] > 1]
|
|
check(f"{tgt}: candidates with several instances or off-table instances keep their grade (A {sum(c['grade'] == 'A' for c in others)})", any(c["grade"] == "A" for c in others))
|
|
print("RESULT", "PASS" if ok else "FAIL")
|
|
sys.exit(0 if ok else 1)
|