"""params <-> expected consistency (C9, 2026-10-20): values that describe the SAME physical quantity in params.json (what the model is built from) and expected.json (what the checkers compare against) must agree. The Rhino gates find every mismatch too, but only after ~150 s; this check finds the stale ones (a value that changed in one file but not in the other) before any gate is run. Origin: Ungok P3 / P4 -- the first gate runs failed almost entirely on values that were updated in params but not in expected (coping steps, pile pitch, hoop chain, B9 grid, tier EL ...) or the other way round. Not an independent check: when both files were written from the same drawing reading they agree trivially. It only says "the two files tell one story". Rules apply to the Ungok layout (coping steps, footing rebar rows ...); a rule whose paths are missing in either file is skipped, so other pier types are not affected. Also contains the point-list rule of improvement #6: `coping_rebar.c14.hooks_up` must name existing `c14.points` (a point list keyed by coordinates breaks silently when the points move). """ import re TOL = 1.5 # mm (drawing values are rounded to 1 mm, CAD vertices to 0.1) def _get(doc, path): x = doc for p in path.split("."): if isinstance(x, list): if not re.fullmatch(r"-?\d+", p) or int(p) >= len(x): return None x = x[int(p)] elif isinstance(x, dict): if p not in x: return None x = x[p] else: return None return x def _num(v): """number of a value that may be a string like '11.856' (schedule cells are metres) -> None when not numeric.""" if isinstance(v, bool) or v is None: return None if isinstance(v, (int, float)): return float(v) try: return float(str(v).replace(",", "")) except ValueError: return None def _rule(rid, text, p, e, tol=TOL, f=None, g=None): """one pairwise rule: params path p, expected path e; f / g transform the values before comparing.""" return dict(id=rid, text=text, p=p, e=e, tol=tol, f=f, g=g) def _steps(n=5): for i in range(n): yield i def rules(params, expected): R = [] A = R.append # footing, lean, column, piles for a, b, t in (("size_x", "dx", "기초 X"), ("size_y", "dy", "기초 Y"), ("z_bottom", "z0", "기초 하면 EL"), ("thickness", "thickness", "기초 두께")): A(_rule("footing." + a, t, "footing." + a, "G1.footing." + b)) A(_rule("column.height", "기둥 높이", "column.height", "G1.column.height")) A(_rule("column.d", "기둥 지름", "column.diameter", "G1.column.d")) for a, b, t in (("count_x", "nx", "말뚝 X 개수"), ("count_y", "ny", "말뚝 Y 개수"), ("length", "length", "말뚝 길이"), ("diameter", "d", "말뚝 지름"), ("edge", "edge", "말뚝 연단")): A(_rule("piles." + a, t, "piles." + a, "G1.piles." + b, tol=0 if a.startswith("count") else TOL)) pp = (params.get("piles") or {}) for ax in ("x", "y"): pit = f"piles.pitch_{ax}" if f"pitch_{ax}" in pp else "piles.pitch" A(_rule(f"piles.pitch_{ax}", f"말뚝 {ax.upper()} 피치", pit, f"G1.piles.pitch_{ax}")) # coping outline: steps, soffit flat for i in _steps(): A(_rule(f"coping.step{i + 1}.z", f"코핑 윗면 {i + 1}단 높이", f"coping.top_steps.{i}.z", f"G1.seats.step_top.{i + 1}")) A(_rule(f"coping.step{i + 1}.z/G4", f"코핑 윗면 {i + 1}단 높이(G4)", f"coping.top_steps.{i}.z", f"G4.step_top.{i + 1}")) A(_rule(f"coping.step{i + 1}.x0", f"코핑 {i + 1}단 시작 x", f"coping.top_steps.{i}.x0", f"G1.seats.step_x.{i + 1}.0")) A(_rule(f"coping.step{i + 1}.x1", f"코핑 {i + 1}단 끝 x", f"coping.top_steps.{i}.x1", f"G1.seats.step_x.{i + 1}.1")) for nm, i in (("left", 0), ("center", 2), ("right", 4)): A(_rule(f"coping.z_top_{nm}", f"코핑 윗면 {nm} 높이", f"coping.top_steps.{i}.z", f"G1.coping.z_top_{nm}")) A(_rule("coping.dx", "코핑 길이", "coping.length_bottom_tip_to_tip", "G1.coping.dx")) A(_rule("coping.dy", "코핑 폭", "coping.width_y", "G1.coping.dy")) A(_rule("coping.flat", "코핑 하면 평평부 높이", "coping.z_bottom_flat", "G1.dxf_arc_centres.datum_el")) A(_rule("coping.outline.flat", "코핑 외곽 하면 높이", "coping.z_bottom_flat", "G1.coping_outline.0.1")) # bearings A(_rule("bearing.pitch", "받침 피치", "bearing_seats.pitch_x", "G1.seats.pitch_chain", tol=1.0, g=lambda v: v[1] / v[0] if isinstance(v, list) and len(v) == 2 and v[0] else None)) A(_rule("bearing.row_y.front", "받침 앞열 y", "bearing_seats.pad_rows.front.centre_rel_girder5.1", "G1.seats.row_y.front", tol=1.5)) A(_rule("bearing.row_y.rear", "받침 뒤열 y", "bearing_seats.pad_rows.rear.centre_rel_girder5.1", "G1.seats.row_y.rear", tol=1.5)) # coping rebar for t, ms in (("1", ("C1", "C4")), ("2", ("C2", "C5")), ("3", ("C3", "C6"))): for m in ms: A(_rule(f"coping_rebar.tier{t}.{m}", f"코핑 상단 철근 {m} 높이", f"coping_rebar.top.tiers.{t}.z", f"G4.tier_z.{m}")) A(_rule("coping_rebar.c15", "C15 높이", "coping_rebar.c15.z", "G4.c15_top")) A(_rule("coping_rebar.c16", "C16 높이", "coping_rebar.c16.z", "G4.c16_top")) A(_rule("coping_rebar.c10_bottom", "C10 스터럽 하단 높이", "coping_rebar.bottom.z_flat", "G4.c10_bottom")) lv = (params.get("coping_rebar") or {}).get("loops", {}).get("levels") if lv: A(_rule("coping_rebar.loop.first", "루프 C12 시작 높이", "coping_rebar.loops.levels.0", "G4.loop_z.C12.start")) for k in range(3): A(_rule(f"coping_rebar.loop.c13.{k}", f"루프 C13 {k + 1}번째 높이", f"coping_rebar.loops.levels.{6 + k}", f"G4.loop_z.C13.{k}")) # column rebar chains A(_rule("column_rebar.hoop.radius", "후프 반경", "column_rebar.hoops.radius", "G2.r_hoop")) A(_rule("column_rebar.hoop.count", "후프 개수", "column_rebar.hoops.levels", "G2.hoop_chain.groups", tol=0, f=lambda v: len(v) if isinstance(v, list) else None, g=lambda gs: 1 + sum(int(n) for n, _ in gs) if isinstance(gs, list) else None)) A(_rule("column_rebar.tie.count", "결속철근 단 수", "column_rebar.ties.levels", "G2.tie_chain.groups", tol=0, f=lambda v: len(v) if isinstance(v, list) else None, g=lambda gs: 1 + sum(int(n) for n, _ in gs) if isinstance(gs, list) else None)) A(_rule("column_rebar.p1.top", "주철근 P1 직선 길이", "column_rebar.main_bars.p1.straight_len", "G2.p1_top_parts.2")) # footing rebar rows (positions include the two end bars) for lay, row in (("bottom_Y1", "Y_BOT"), ("bottom_X2", "X_BOT"), ("bottom_Y3", "Y_250"), ("bottom_X4", "X_250"), ("top_Y", "Y_250"), ("top_X", "X_250")): A(_rule(f"footing_rebar.{lay}.count", f"기초 철근 {lay} 개수", f"footing_rebar.layers.{lay}.pos", f"G3.rows.{row}.n", tol=0, f=lambda v: len(v) if isinstance(v, list) else None, g=lambda n: n + 2 if isinstance(n, (int, float)) else None)) A(_rule(f"footing_rebar.{lay}.end", f"기초 철근 {lay} 끝", f"footing_rebar.layers.{lay}.pos.-1", f"G3.rows.{row}.ends.1")) A(_rule("footing_rebar.b9.x", "B9 격자 x 개수", "footing_rebar.b9.grid_x", "G3.b9_grid.x.n", tol=0, f=lambda v: len(v) if isinstance(v, list) else None)) A(_rule("footing_rebar.b9.y", "B9 격자 y 개수", "footing_rebar.b9.grid_y", "G3.b9_grid.y.n", tol=0, f=lambda v: len(v) if isinstance(v, list) else None)) A(_rule("footing_rebar.b9.x0", "B9 격자 x 시작", "footing_rebar.b9.grid_x.0", "G3.b9_grid.x.start")) A(_rule("footing_rebar.b9.y0", "B9 격자 y 시작", "footing_rebar.b9.grid_y.0", "G3.b9_grid.y.start")) A(_rule("footing_rebar.b10.x", "B10 x", "footing_rebar.b10.size_x", "G3.b10_size.0")) A(_rule("footing_rebar.b10.y", "B10 y", "footing_rebar.b10.size_y", "G3.b10_size.1")) # schedule copies for m in ("P1", "P2", "P3", "P4", "P5", "P6"): A(_rule(f"schedule.{m}.n", f"재료표 {m} 개수", f"column_rebar.schedule.{m}.n", f"G2.schedule_n.{m}", tol=0)) A(_rule(f"schedule.{m}.L", f"재료표 {m} 길이", f"column_rebar.schedule.{m}.L", f"G2.schedule_L.{m}", f=lambda v: None if _num(v) is None else _num(v) * 1000)) for i in range(1, 13): m = f"B{i}" sch = (params.get("footing_rebar") or {}).get("schedule", {}).get(m) if sch is None: continue # raw schedule rows: B9 has an extra count column in newer drawings; counts are compared where the row has one A(_rule(f"schedule.{m}.n", f"재료표 {m} 개수", f"footing_rebar.schedule.{m}.3", f"G3.schedule.{m}.1", tol=0, f=lambda v: _num(v) if _num(v) is not None and float(v) == int(float(v)) else None)) return R def check(params, expected): """-> list of {id, text, p, e, pv, ev, ok} for every rule whose two values exist, plus the point-list rule.""" out = [] for r in rules(params, expected): pv, ev = _get(params, r["p"]), _get(expected, r["e"]) if r["f"]: pv = r["f"](pv) if r["g"]: ev = r["g"](ev) a, b = _num(pv), _num(ev) if a is None or b is None: continue out.append(dict(id=r["id"], text=r["text"], p=r["p"], e=r["e"], pv=a, ev=b, ok=abs(a - b) <= r["tol"])) # improvement #6: a list of points addressed by coordinates must name existing points c14 = _get(params, "coping_rebar.c14") if isinstance(c14, dict) and isinstance(c14.get("hooks_up"), list) and isinstance(c14.get("points"), list): pts = {(round(x, 1), round(z, 1)) for x, z in c14["points"]} for i, h in enumerate(c14["hooks_up"]): ok = (round(h[0], 1), round(h[1], 1)) in pts out.append(dict(id=f"coping_rebar.c14.hooks_up.{i}", text="c14 갈고리 위 지정 점이 c14 점 목록에 있다", p=f"coping_rebar.c14.hooks_up.{i}", e="coping_rebar.c14.points", pv=f"({h[0]}, {h[1]})", ev="목록에 없음" if not ok else "있음", ok=ok)) return out def bundles_of(path): """'footing.size_x' -> 'footing' (the section key of a dotted path).""" return path.split(".", 1)[0]