"""Gate runner: build a gate model, re-open it, check it, run negative tests and judge the gate. A gate Gk passes when all of: checks every FAIL of checker Gk is an approved planned exception (expected.json `planned_fail`) negative a horizontal and a vertical move of one gate object each raise the FAIL count reload the re-opened file has the same object count as the built document unchanged objects of earlier gates are identical to the previous gate model (fast fingerprint) Files go to : Gk.3dm, Gk.json, Gk_negH.json, Gk_negV.json, Gk_fp.json, Gk_gate.json. """ import json import sys import time from pathlib import Path from engine.pipeline import (CHECKERS, GATE_PREFIXES, GATES, REBAR_GATES, RHINO_DIR, TOLERANCE_MM, build_steps, builder_of, check_steps, checker_of, gate_ids, scope_of) APP = Path(__file__).resolve().parents[1] sys.path.insert(0, str(APP)) sys.path.insert(0, str(APP / "tools")) from compare_fingerprints import diff_obj # noqa: E402 from runner import run_job # noqa: E402 # Negative tests per gate: one horizontal and one vertical move of a gate object (mm). NEGATIVE = { "G1": ({"id": "BS08", "d": [3, 0, 0]}, {"id": "BP03", "d": [0, 0, 2]}), "G2": ({"mark": "P2", "d": [5, 0, 0]}, {"mark": "P3", "d": [0, 0, 5]}), "G3": ({"mark": "B4", "d": [5, 0, 0]}, {"mark": "B9", "d": [0, 0, 5]}), "G4": ({"mark": "C11", "d": [5, 0, 0]}, {"mark": "S1", "d": [0, 0, 5]}), "G5": ({"id": "BB05", "d": [5, 0, 0]}, {"id": "SP08", "d": [0, 0, 5]}), } def _load(p): return json.loads(Path(p).read_text(encoding="utf-8")) def planned(expected, gate, item): for pf in expected.get("planned_fail", []): if pf["gate"] != gate: continue if pf.get("item") == item or (pf.get("item_prefix") and item.startswith(pf["item_prefix"])): return pf return None def _fails(res): return [r for r in res["results"] if r["result"] == "FAIL"] def negative_moves(X, gate): """Negative-test moves of a gate: expected["negative"][gate] (pier specific) or the Ungok default. A default bearing id beyond this pier's bearings (BS08 / SP08 with 4 bearings) is replaced by the last bearing number of expected G5 el_x (OPEN #19 A7); with 10 bearings the default is unchanged.""" ov = X.get("negative", {}).get(gate) if ov: return tuple(ov) nos = sorted(int(k) for k in (X.get("G5", {}).get("el_x") or {}) if str(k).isdigit()) return tuple(dict(mv, id=f"{mv['id'][:2]}{nos[-1]:02d}") if "id" in mv and nos and int(mv["id"][2:]) not in nos else mv for mv in NEGATIVE[gate]) def _negative_steps(gate, pid, expected, run_dir): """Rhino steps: checker, then horizontal / vertical move -> checker -> undo (same document).""" neg_h, neg_v = negative_moves(_load(expected), gate) pert = RHINO_DIR / "perturb.py" steps = check_steps(expected, run_dir, [gate]) for tag, mv in (("_negH", neg_h), ("_negV", neg_v)): glb = {"PIER_ID": pid, "MOVES": [mv]} steps.append({"name": f"{gate}:perturb{tag}", "script": pert, "globals": glb}) steps += check_steps(expected, run_dir, [gate], tag) steps.append({"name": f"{gate}:undo{tag}", "script": pert, "globals": dict(glb, UNDO=True)}) return steps def _judge(gate, X, run_dir): """Read checker + negative-test results: (base, unplanned, planned_seen, neg).""" base = _load(run_dir / f"{gate}.json") fails = _fails(base) unplanned = [f["item"] for f in fails if not planned(X, gate, f["item"])] planned_seen = [{"item": f["item"], "reason": planned(X, gate, f["item"])["reason"]} for f in fails if planned(X, gate, f["item"])] neg = {} for tag, mv in zip(("negH", "negV"), negative_moves(X, gate)): r = _load(run_dir / f"{gate}_{tag}.json") new = [f["item"] for f in _fails(r) if f["item"] not in {x["item"] for x in fails}] neg[tag] = {"move": mv, "new_fails": new[:10], "detected": len(_fails(r)) > len(fails)} return base, unplanned, planned_seen, neg def run_batch_session(gates, params, expected, run_dir, log=print): """Batch mode: build G1..G5 in ONE Rhino session; after each gate in `gates` is built, run its checker and negative tests in the same document. No per-gate save and no reload; the final model is saved once as G5.3dm. Writes _gate.json for each gate in `gates` (reload / unchanged = skipped) and returns {gate: report}.""" run_dir = Path(run_dir) run_dir.mkdir(parents=True, exist_ok=True) X = _load(expected) pid = X["pier_id"] ids = gate_ids() for g in gates: # results / models of these gates are replaced: drop the old ones for suffix in ("_fp.json",) + ((".3dm",) if g != "G5" else ()): (run_dir / f"{g}{suffix}").unlink(missing_ok=True) glb = {"PARAMS_PATH": str(Path(params).resolve())} steps = [] scope = scope_of(params) for g, _, _ in GATES: if g not in scope: continue script = builder_of(params, g) steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)}) if g in REBAR_GATES: steps.append({"name": f"{g}:rebar_colours.py", "script": RHINO_DIR / "rebar_colours.py", "globals": glb}) if g in gates: steps += _negative_steps(g, pid, expected, run_dir) steps.append({"name": "display.py", "script": RHINO_DIR / "display.py", "globals": glb}) t0 = time.time() log(f"[batch] one Rhino session: build G1..G5, check {', '.join(gates)} (no per-gate save / reload)") res = run_job(steps, save=run_dir / "G5.3dm", units_mm_tolerance=TOLERANCE_MM) secs = {} for st in res["steps"]: g = st["name"].split(":")[0] secs[g] = secs.get(g, 0) + st["secs"] out = {} for g in gates: base, unplanned, planned_seen, neg = _judge(g, X, run_dir) verdict = "PASS" if not unplanned and all(v["detected"] for v in neg.values()) else "FAIL" rep = {"gate": g, "checker": checker_of(expected, g), "verdict": verdict, "summary": base["summary"], "unplanned_fails": unplanned, "planned_fails": planned_seen, "negative": neg, "reload": {"ok": None, "skipped": "일괄 실행(단일 세션) — 저장·재로드 안 함"}, "unchanged": {"ok": None, "compared": 0, "diffs": [], "missing": [], "against": None, "skipped": "일괄 실행(단일 세션) — 앞 게이트 객체를 다시 만들지 않음"}, "model": str(run_dir / "G5.3dm") if g == "G5" else None, "mode": "batch", "secs": round(secs.get(g, 0), 1), "session_secs": res["wall_secs"]} (run_dir / f"{g}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8") log(f"[{g}] {verdict} checks {base['summary']['passed']}/{base['summary']['total']} " f"(planned FAIL {len(planned_seen)}, unplanned {len(unplanned)}) negative H/V " f"{neg['negH']['detected']}/{neg['negV']['detected']} (batch)") out[g] = rep log(f"[batch] done in {round(time.time() - t0, 1)}s, objects {res['objects']}, model {run_dir / 'G5.3dm'}") return out def run_gate(gate, params, expected, run_dir, log=print): run_dir = Path(run_dir) run_dir.mkdir(parents=True, exist_ok=True) X = _load(expected) pid = X["pier_id"] t0 = time.time() model = run_dir / f"{gate}.3dm" log(f"[{gate}] build G1..{gate}") built = run_job(build_steps(params, gate), save=model, units_mm_tolerance=TOLERANCE_MM) steps = _negative_steps(gate, pid, expected, run_dir) steps.append({"name": "fingerprint", "script": RHINO_DIR / "fingerprint.py", "globals": {"MODEL_PATH": str(model), "OUT_PATH": str(run_dir / f"{gate}_fp.json"), "FAST": True}}) log(f"[{gate}] re-open, check, negative tests, fingerprint") checked = run_job(steps, open_path=model) base, unplanned, planned_seen, neg = _judge(gate, X, run_dir) fp = _load(run_dir / f"{gate}_fp.json") unchanged = {"compared": 0, "diffs": [], "missing": [], "ok": True, "against": None} ids = scope_of(params) # previous gate = previous gate in this pier's scope k = ids.index(gate) if k > 0: prev_fp = run_dir / f"{ids[k - 1]}_fp.json" if k > 0 and not prev_fp.exists(): unchanged.update(ok=None, skipped=f"{ids[k - 1]} 지문 없음(일괄 실행 결과) — 비교 생략") elif k > 0: prev = _load(prev_fp) earlier = tuple(f"{pid}-{p}" for g in ids[:k] for p in GATE_PREFIXES[g]) keys = [oid for oid in prev["objects"] if oid.startswith(earlier)] unchanged["against"] = prev_fp.name unchanged["compared"] = len(keys) unchanged["missing"] = [oid for oid in keys if oid not in fp["objects"]][:10] for oid in keys: if oid in fp["objects"]: d = diff_obj(prev["objects"][oid], fp["objects"][oid]) if d: unchanged["diffs"].append([oid, d[:3]]) unchanged["diffs"] = unchanged["diffs"][:10] unchanged["ok"] = not unchanged["missing"] and not unchanged["diffs"] reload_ok = built["objects"] == checked["objects"] == fp["count"] verdict = ("PASS" if not unplanned and all(v["detected"] for v in neg.values()) and reload_ok and unchanged["ok"] is not False else "FAIL") rep = {"gate": gate, "checker": checker_of(expected, gate), "verdict": verdict, "summary": base["summary"], "unplanned_fails": unplanned, "planned_fails": planned_seen, "negative": neg, "reload": {"built": built["objects"], "reopened": checked["objects"], "fingerprint": fp["count"], "ok": reload_ok}, "unchanged": unchanged, "model": str(model), "mode": "single", "secs": round(time.time() - t0, 1), "build_steps": [{k_: s[k_] for k_ in ("name", "secs")} for s in built["steps"]]} (run_dir / f"{gate}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8") log(f"[{gate}] {verdict} checks {base['summary']['passed']}/{base['summary']['total']} " f"(planned FAIL {len(planned_seen)}, unplanned {len(unplanned)}) negative H/V " f"{neg['negH']['detected']}/{neg['negV']['detected']} reload {reload_ok} " f"unchanged {unchanged['ok']} ({unchanged['compared']}) {rep['secs']}s") return rep