"""Variability classes of the input bundles (stage A of the 입력 묶음 확인·확정 개편, 2026-10-16). Every top-level item of params.json / expected.json is a bundle. Grades (기획서 4.4): V1 value variable numbers / text that change from drawing to drawing within the same form V2 list variable position lists, dimension chains, outline points V3 generated variable long lists made by a reading script (hand editing is blocked) F fixed defines the form; changing it means "another template" (edit needs a reason) `fixed_fields` are paths inside a non-F bundle that behave like F (e.g. coping.form); "*" matches every child of an object. `source` lists where the values normally come from: 표 / 표 밖 치수 / 판독 스크립트 / 사람 입력 / 템플릿 고정. The result is a draft until the template's owner confirms it (variability.json of each protected template). """ import json SOURCES = ["표", "표 밖 치수", "판독 스크립트", "사람 입력", "템플릿 고정"] GRADES = {"V1": "값 변수", "V2": "목록 변수", "V3": "생성 변수", "F": "고정"} V3_MIN_BARS = 100 # a rebar bundle with at least this many explicit bars is a generated list # key -> (grade, [sources], [fixed_fields], note); the same table serves params and expected (file-specific rows below) PARAMS = { "pier_id": ("F", ["템플릿 고정"], [], "교각 식별자"), "scope": ("F", ["템플릿 고정"], [], "모델링 게이트 범위(실행 설정)"), "builders": ("F", ["템플릿 고정"], [], "게이트별 생성기(실행 설정)"), "display": ("F", ["템플릿 고정"], [], "표시·색 설정"), "lean_concrete": ("V1", ["표 밖 치수"], [], "버림콘크리트 치수·EL"), "footing": ("V1", ["표 밖 치수"], [], "기초 치수·EL (rot_deg = 기초 회전 각도 값)"), "column": ("V1", ["표 밖 치수"], ["shape"], "기둥 지름·높이 (단면 형식 shape는 고정)"), "materials": ("V1", ["표 밖 치수"], [], "재료 강도 주기"), "piles": ("V1", ["표 밖 치수"], ["kind"], "말뚝 개수·간격·길이 (말뚝 유무는 템플릿 형식)"), "pile": ("V1", ["표 밖 치수"], ["kind"], "단일 말뚝 구간"), "coping": ("V2", ["표 밖 치수"], ["form"], "코핑 외곽·치수 (형식 form은 고정)"), "bearing_seats": ("V1", ["표", "표 밖 치수"], ["pedestal", "pedestals"], "받침 표 값 + 평면 위치 (대좌 구조는 고정)"), "sole_plates": ("V1", ["표"], ["bearing_size"], "솔플레이트 표 값 (받침 크기 규칙은 고정)"), "rebar_marks": ("V1", ["표"], ["*.shape"], "재료표 직경·형상 (마크 체계의 형상은 고정)"), "column_rebar": ("V2", ["표 밖 치수"], ["form"], "기둥 철근 사슬·위치"), "footing_rebar": ("V2", ["표 밖 치수"], ["form"], "기초 철근 위치 사슬"), "shaft_rebar": ("V2", ["표 밖 치수"], ["form"], "말뚝 철근"), "coping_rebar": ("V2", ["표 밖 치수"], ["form", "host", "group"], "코핑 철근"), } EXPECTED = { "pier_id": ("F", ["템플릿 고정"], [], "교각 식별자"), "tol_mm": ("V1", ["사람 입력"], [], "허용 오차"), "checkers": ("F", ["템플릿 고정"], [], "게이트별 검사기(실행 설정)"), "negative": ("F", ["템플릿 고정"], [], "음성 시험 대상(실행 설정)"), "rebar_palette": ("V1", ["사람 입력"], [], "철근 직경별 색 (사람 결정 기록)"), "planned_fail": ("V1", ["사람 입력"], [], "계획된 FAIL (사람 결정 기록)"), **{g: ("V2", ["표", "표 밖 치수"], [], "독립 기대값 (params와 따로 도면에서 옮긴 값)") for g in ("G1", "G2", "G3", "G4", "G5")}, } def is_info(key): return key.startswith("_") or key.endswith("_src") or key.startswith("hitl_decisions") def _bars(value): """Number of explicit per-bar entries of a rebar bundle (list `bars`), 0 when the bundle is rule based.""" return len(value.get("bars", [])) if isinstance(value, dict) and isinstance(value.get("bars"), list) else 0 def classify(which, key, value): """-> {grade, source, fixed_fields, note, info}.""" if is_info(key): return dict(grade="F", source=["템플릿 고정"], fixed_fields=[], note="참고 정보(게이트가 쓰지 않음)", info=True) grade, source, fixed, note = (PARAMS if which == "params" else EXPECTED).get(key, ("V1", ["사람 입력"], [], "미분류 — 담당자 확인")) if which == "params" and key in ("coping_rebar", "footing_rebar") and _bars(value) >= V3_MIN_BARS: grade, source, note = "V3", ["판독 스크립트"], note + " — 철근별 중심선 목록(판독 스크립트로 생성)" return dict(grade=grade, source=list(source), fixed_fields=list(fixed), note=note, info=False) def build(params, expected): """variability.json content for a params / expected pair (draft).""" sections = {} for which, d in (("params", params), ("expected", expected)): for k, v in d.items(): sections[f"{which}:{k}"] = classify(which, k, v) return {"_schema": "variability/1", "status": "초안 — 템플릿 담당자 확정 전", "grades": GRADES, "sections": sections} def _walk(value, parts): """Values at a path with "*" wildcards, as a list of (path, value); a missing path yields (path, None).""" if not parts: return [("", value)] head, rest = parts[0], parts[1:] if head == "*": if not isinstance(value, dict): return [] out = [] for k, v in value.items(): out += [(f"{k}.{p}".rstrip("."), x) for p, x in _walk(v, rest)] return out if not isinstance(value, dict) or head not in value: return [(head, None)] return [(f"{head}.{p}".rstrip("."), x) for p, x in _walk(value[head], rest)] def fixed_view(value, fixed_fields): """{path: json text} of the fixed fields of a bundle value (what must not change silently).""" out = {} for f in fixed_fields: for p, x in _walk(value, f.split(".")): out[p] = json.dumps(x, sort_keys=True, ensure_ascii=False) return out def changed_fixed(old, new, fixed_fields): """Paths of fixed fields whose value differs between two bundle values.""" a, b = fixed_view(old, fixed_fields), fixed_view(new, fixed_fields) return sorted(p for p in set(a) | set(b) if a.get(p) != b.get(p))