From 31c2602f4b1c24eeb31ad3eb2b84e786f37fd4d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 17:02:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EC=B0=B0=EC=8C=93=EA=B8=B0=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=20=ED=95=9C=20=EB=B2=8C=20+=20=EC=8B=9D=20?= =?UTF-8?q?=EC=B9=B8=20=EC=85=8B(when=C2=B7destination=C2=B7spec)=20?= =?UTF-8?q?=E2=80=94=20=EC=A7=80=EA=B8=88=20=EC=A0=84=EA=B0=9C=EC=99=80=20?= =?UTF-8?q?=EA=B0=92=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 풀이기에 명세 13장 보강분: when(안 선 줄, 가리키면 오류) · destination 필수(기본값 없음) · spec 의 {제원} 채움 · 반올림 trunc(ROUNDDOWN)·ceil_away(ROUNDUP) - resources/library_structure/masonry_wet.json — 줄 15 · 뒷길이×돌종류 표 · 제원 vars · 물구멍 2.5㎡ 실무 관측은 대안 후보로 - 양식 제원 채우기 B08_Quantity_Engine_StructureTemplate.py(계산 없음) - 대조 시험 121건: 높이·뒷길이·돌종류·기초·사용자 칸·when 갈래에서 전개와 한 줄도 안 갈림. 알려진 차이 하나(실무 관행 계수가 돌종류를 지우는 전개 결함)는 시험에 드러냄 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureTemplate.py | 74 +++++ B08_Quantity/B08_Quantity_Formula.ts | 113 ++++++-- resources/library_structure/masonry_wet.json | 260 ++++++++++++++++++ resources/tester/test_b08_formula.py | 65 +++++ .../test_b08_structure_template_parity.py | 136 +++++++++ 5 files changed, 629 insertions(+), 19 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_StructureTemplate.py create mode 100644 resources/library_structure/masonry_wet.json create mode 100644 resources/tester/test_b08_structure_template_parity.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py new file mode 100644 index 00000000..f5028a64 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -0,0 +1,74 @@ +"""구조물도 **양식형 항목** 읽기 — 양식 파일 + 구조물 제원 → 식 풀이기에 넘길 장 한 벌. + +양식은 `resources/library_structure/.json`(4장 프로그램 기본 자리 · 명세 13장 식 칸 계약). +풀이는 여기 없음 — `B08_Quantity_Engine_Formula.evaluate_sheets`(TS 한 벌을 Node 로)가 풂. + +⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이 + 수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE_DIR = ROOT / "resources" / "library_structure" + + +def load_template(type_id: str) -> dict[str, Any] | None: + """프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.""" + path = TEMPLATE_DIR / f"{type_id}.json" + if not path.is_file(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def _typed(value: Any, default: Any) -> Any: + """제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글.""" + if isinstance(default, (int, float)) and not isinstance(default, bool): + try: + return float(value) + except (TypeError, ValueError): + return default + return str(value) + + +def template_vars( + template: dict[str, Any], + structure: dict[str, Any], + judged_slope: float, + settings: dict[str, Any] | None = None, +) -> dict[str, Any]: + """양식이 적은 제원 칸을 구조물·산출 조건에서 채움. 빈 칸은 양식 기본값. + + ⚠ 전면 기울기는 **전개가 판정한 값**을 받음 — 표준경사 판정(성절토·직고)은 식 언어 밖이라 + 판정 한 벌(`face_slope_ratio`)을 그대로 씀. + """ + options = structure.get("options") or {} + values: dict[str, Any] = {} + for name, spec in (template.get("vars") or {}).items(): + default = spec.get("default") + source = spec.get("source") + if source == "judged_face_slope": + values[name] = float(judged_slope) + continue + if source: + values[name] = float(structure.get(source) or 0.0) + continue + if "option" in spec: + raw = options.get(spec["option"]) + else: + raw = (settings or {}).get(spec.get("setting")) + values[name] = default if raw in (None, "") else _typed(raw, default) + return values + + +def template_sheet(template: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: + """식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로, 제원만 끼움.""" + return { + "rows": template.get("rows") or [], + "vars": values, + "tables": template.get("tables") or {}, + } diff --git a/B08_Quantity/B08_Quantity_Formula.ts b/B08_Quantity/B08_Quantity_Formula.ts index e41828be..d5eb09f3 100644 --- a/B08_Quantity/B08_Quantity_Formula.ts +++ b/B08_Quantity/B08_Quantity_Formula.ts @@ -14,21 +14,39 @@ * ⚠ `eval` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음. * ========================================================================== */ -export type RoundingMode = "floor" | "round" | "ceil" | "none" | "round_half_even"; +/** + * 반올림 갈래 — 엑셀 대응(명세 13장 대응표): `INT`=floor · `ROUNDDOWN`=trunc · `ROUNDUP`=ceil_away · + * `ROUND`=round(사사오입). ⚠ 양수에서는 floor=trunc · ceil=ceil_away 라 **음수에서만 갈림**. + */ +export type RoundingMode = + "floor" | "trunc" | "round" | "ceil_away" | "ceil" | "none" | "round_half_even"; export interface FormulaRounding { mode: RoundingMode; digits: number; } +/** 갈 곳 — 이중계상 경계를 줄 단위로(명세 13장 Ⓑ). ⛔ 빠지면 오류, 기본값 없음. */ +export const DESTINATIONS = [ + "earthwork", + "material", + "unit_price", + "reference", + "haul_deduction", +] as const; + /** 식 칸 한 줄 — 명세 13장 「한 줄이 들고 갈 칸」. */ export interface FormulaRow { seq: number; name: string; + /** 규격. `{제원이름}` 은 그 제원 값으로 바뀜 — 이름은 고정하고 종류는 여기로(명세 13장 Ⓒ). */ spec?: string; /** 기계가 푸는 식(정본). 비면 고정형 — `amount` 를 박힌 값으로 씀. */ formula?: string | null; formula_text?: string; + /** 줄이 서느냐 — 거짓이면 그 줄은 **안 섬**(0 이 아님, 명세 13장 Ⓐ). */ + when?: string | null; + destination?: string; refs?: Record; vars?: Record; amount?: string | number | null; @@ -53,10 +71,15 @@ export interface FormulaSheet { export interface FormulaRowResult { seq: number; name: string; - /** 반올림 뒤 값(십진 문자열). 오류면 `null`. */ + /** `{제원}` 을 채운 규격. */ + spec: string; + /** 반올림 뒤 값(십진 문자열). 오류·안 섬이면 `null`. */ amount: string | null; /** 반올림 전 값 — 어느 자리에서 갈렸는지 되짚는 용. */ raw: string | null; + /** `when` 이 거짓이라 안 선 줄 — 화면은 「안 섬」과 `reason` 을 보임. */ + skipped: boolean; + reason: string | null; error: string | null; } @@ -133,6 +156,8 @@ function toInteger(x: Frac, mode: RoundingMode): bigint { const negative = x.n < 0n; if (mode === "floor") return negative ? q - 1n : q; if (mode === "ceil") return negative ? q : q + 1n; + if (mode === "trunc") return q; // 엑셀 ROUNDDOWN — 0 쪽 + if (mode === "ceil_away") return negative ? q - 1n : q + 1n; // 엑셀 ROUNDUP — 0 에서 먼 쪽 const twice = abs(r) * 2n; if (twice === x.d && mode === "round_half_even") return q % 2n === 0n ? q : negative ? q - 1n : q + 1n; @@ -336,8 +361,13 @@ function parse(source: string): Node { type Value = Frac | string | boolean; +/** 안 선 줄·오류 난 줄을 가리키는 이름 — 쓰이는 순간 그 까닭으로 막음. */ +class Blocked { + constructor(readonly message: string) {} +} + interface Scope { - names: Map; + names: Map; tables: Record; } @@ -407,6 +437,8 @@ function evaluate(node: Node, scope: Scope): Value { case "name": { const value = scope.names.get(node.name); if (value === undefined) throw new FormulaError(`모르는 이름: ${node.name}`); + // 안 선 줄·오류 난 줄은 **쓰일 때** 막음 — `when` 이 그 이름을 안 쓰면 줄은 그대로 판정됨. + if (value instanceof Blocked) throw new FormulaError(value.message); return value; } case "unary": { @@ -459,39 +491,82 @@ function evaluate(node: Node, scope: Scope): Value { } } +const ROUNDING_MODES = new Set([ + "floor", + "trunc", + "round", + "ceil_away", + "ceil", + "none", + "round_half_even", +]); + +/** `{제원}` 을 제원 값으로 — 모르는 이름은 그대로 두지 않고 오류(조인 키가 흔들림). */ +function fillSpec(spec: string, vars: Record): string { + return spec.replace(/\{([^{}]+)\}/g, (_, key: string) => { + const value = vars[key]; + if (value === undefined) throw new FormulaError(`규격의 모르는 제원: ${key}`); + return typeof value === "number" ? fracToString(toFrac(value)) : value; + }); +} + /** * 장 한 벌을 줄 차례대로 풂. 오류는 **그 줄에만** 적고 다음 줄은 계속 풂 — - * 오류 난 줄을 가리키는 줄은 「앞 줄 오류」로 막힘(0 으로 때우지 않음). + * 오류 난 줄·안 선 줄을 가리키는 줄은 막힘(0 으로 때우지 않음, 명세 13장 Ⓐ). */ export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] { const rows = [...(sheet.rows ?? [])].sort((a, b) => a.seq - b.seq); - const done = new Map(); + const done = new Map(); const results: FormulaRowResult[] = []; for (const row of rows) { const result: FormulaRowResult = { seq: row.seq, name: row.name, + spec: row.spec ?? "", amount: null, raw: null, + skipped: false, + reason: null, error: null, }; try { if (done.has(row.seq)) throw new FormulaError(`같은 차례 번호가 둘: ${row.seq}`); + // ⛔ 갈 곳 기본값 없음 — 빠진 줄이 자재총괄에서 조용히 사라진 결함이 실재함(명세 13장 Ⓑ). + if (!row.destination) throw new FormulaError("갈 곳(destination)이 없음"); + if (!(DESTINATIONS as readonly string[]).includes(row.destination)) { + throw new FormulaError(`모르는 갈 곳: ${row.destination}`); + } + if (row.rounding && !ROUNDING_MODES.has(row.rounding.mode)) { + throw new FormulaError(`모르는 반올림: ${row.rounding.mode}`); + } + const vars = { ...(sheet.vars ?? {}), ...(row.vars ?? {}) }; + result.spec = fillSpec(row.spec ?? "", vars); + const names = new Map(); + for (const [key, value] of Object.entries(vars)) { + names.set(key, typeof value === "number" ? toFrac(value) : value); + } + for (const [key, seq] of Object.entries(row.refs ?? {})) { + if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key} → ${seq}`); + const target = done.get(seq); + if (!target) throw new FormulaError(`없는 줄: ${key} → ${seq}`); + names.set( + key, + target.value ?? + new Blocked(`앞 줄 ${seq}(${target.name}) ${target.skipped ? "안 섬" : "오류"}`), + ); + } + const scope: Scope = { names, tables: sheet.tables ?? {} }; + if (row.when && row.when.trim() && !truthy(evaluate(parse(row.when), scope))) { + result.skipped = true; + result.reason = `조건이 거짓: ${row.when}`; + done.set(row.seq, { value: null, name: row.name, skipped: true }); + results.push(result); + continue; + } let raw: Frac; if (row.formula && row.formula.trim()) { - const names = new Map(); - for (const [key, value] of Object.entries({ ...(sheet.vars ?? {}), ...(row.vars ?? {}) })) { - names.set(key, typeof value === "number" ? toFrac(value) : value); - } - for (const [key, seq] of Object.entries(row.refs ?? {})) { - if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key} → ${seq}`); - const target = done.get(seq); - if (!target) throw new FormulaError(`없는 줄: ${key} → ${seq}`); - if (!target.value) throw new FormulaError(`앞 줄 ${seq}(${target.name}) 오류`); - names.set(key, target.value); - } - raw = asFrac(evaluate(parse(row.formula), { names, tables: sheet.tables ?? {} })); + raw = asFrac(evaluate(parse(row.formula), scope)); } else { // 고정형 — 박힌 값을 그대로(명세 13장 「양식형 ↔ 고정형」). if (row.amount === null || row.amount === undefined || row.amount === "") { @@ -502,10 +577,10 @@ export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] { const value = applyRounding(raw, row.rounding); result.raw = fracToString(raw); result.amount = fracToString(value); - done.set(row.seq, { value, name: row.name }); + done.set(row.seq, { value, name: row.name, skipped: false }); } catch (error) { result.error = error instanceof Error ? error.message : String(error); - done.set(row.seq, { value: null, name: row.name }); + done.set(row.seq, { value: null, name: row.name, skipped: false }); } results.push(result); } diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json new file mode 100644 index 00000000..31b8b90a --- /dev/null +++ b/resources/library_structure/masonry_wet.json @@ -0,0 +1,260 @@ +{ + "schema_version": 1, + "library_tier": "program", + "item_kind": "form", + "type_id": "masonry_wet", + "name": "돌쌓기(찰)", + "note": "구조물도 양식형 항목 첫 벌(PLAN 3장 ② · 명세 13장 식 칸 계약). 값은 지금 전개(`B08_Quantity_Engine_UnitQuantity.stone_masonry` + 기초잡석)와 같게 둠(판정 Ⓑ). 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않고 제원 vars 와 표 한 벌로 수량이 다시 남(명세 16장).", + "differs_from_engine": [ + "원문 「-」 칸(야면석 75·깬돌 25·견치돌 25/30 고임돌)에서 전개는 고임돌 줄을 안 세우고 채집석에 0 을 더함. 이 양식은 고임돌 줄이 「표 칸이 비어 있음」 오류로 서고 채집석도 막힘 — 명세 13장 Ⓐ(안 선 줄을 0 으로 치지 않음)을 따른 것", + "돌 줄 이름 — 전개는 돌종류 이름(야면석·호박돌…/돌), 양식은 이름 「돌」 고정 + spec 에 종류(명세 13장 Ⓒ)", + "「실무 관행」 계수를 고르면 전개는 돌종류까지 지워 돌 줄을 「돌」·계산식 무게로 세움(야면석을 골라도). 관행은 계수 열만 바꾸는 칸이라 전개 쪽 결함으로 보고 양식은 종류를 지킴 — 판정 요청" + ], + "vars": { + "H": { "label": "높이(m)", "source": "height_m" }, + "L": { "label": "연장(m)", "source": "length_m" }, + "N": { + "label": "전면 기울기 1:n", + "source": "judged_face_slope", + "note": "사용자 값이 있으면 그 값, 없으면 품셈 13-4-4 [주]⑪ 표준경사를 전개가 판정해 넘김" + }, + "L3": { "label": "뒷길이(㎝)", "option": "back_len_cm", "default": 45 }, + "돌종류": { "label": "돌 종류", "option": "stone_kind", "default": "" }, + "계수기준": { "label": "야면석 계수", "option": "stone_coeff_basis", "default": "" }, + "TOP_IN": { + "label": "상부 두께(m) — 0 은 안 정함", + "option": "thickness_top_m", + "default": 0 + }, + "BOT_IN": { + "label": "하부 두께(m) — 0 은 안 정함", + "option": "thickness_bottom_m", + "default": 0 + }, + "기초": { "label": "기초(기초유·기초버림)", "option": "foundation", "default": "" }, + "BLIND": { "label": "버림 콘크리트(넣음·안 넣음)", "option": "blinding_concrete", "default": "넣음" }, + "SUPPLY": { "label": "돌 조달(채집·구입)", "option": "stone_supply", "default": "채집" }, + "MPA": { "label": "채움 콘크리트 강도(MPa)", "option": "fill_concrete_mpa", "default": "210" }, + "HOLE_DIA": { "label": "물구멍 지름(㎜)", "option": "weep_hole_diameter_mm", "default": 50 }, + "HOLE_AREA": { + "label": "물구멍 1개소당 벽면적(㎡)", + "option": "weep_hole_area_m2", + "default": 2, + "candidates": [ + { "value": 2.5, "source": "소광리 07-구조도 「돌기슭막이(45)찰」 실무 관측 — ea/2.5㎡" } + ] + }, + "RUBBLE_T": { + "label": "기초잡석 두께(m)", + "setting": "rubble_base_thickness_m", + "default": 0.2 + } + }, + "tables": { + "고임돌표": { + "note": "품셈 13-4-3 고임돌 ㎥/㎡ · 「참고자료」 열은 돌종류 미지정·실무 관행일 때(건설품셈 참고자료) · null 은 원문 「-」", + "keys": [25, 30, 35, 45, 55, 60, 75], + "columns": { + "야면석·호박돌": [0.06, 0.07, 0.09, 0.11, 0.14, 0.15, null], + "깬잡석": [0.09, 0.11, 0.13, 0.16, 0.19, 0.21, 0.26], + "깬돌": [null, 0.1, 0.12, 0.15, 0.18, 0.2, 0.25], + "견치돌": [null, null, 0.12, 0.15, 0.18, 0.2, 0.25], + "참고자료": [null, 0.1, 0.12, 0.15, 0.18, 0.2, 0.25] + } + }, + "채움표": { + "note": "품셈 13-4-4 [주]① 채움 콘크리트 ㎥/㎡", + "keys": [25, 30, 35, 45, 55, 60, 75], + "columns": { + "야면석·호박돌": [0.08, 0.1, 0.12, 0.15, 0.18, 0.2, 0.25], + "깬잡석": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34], + "깬돌": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34], + "견치돌": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34], + "참고자료": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34] + } + }, + "돌중량표": { + "note": "야면석 돌중량 ton/㎡ — 울진 소광 원단위 라이브러리 실무 관측(35·45·55㎝만). 다른 종류는 계산식", + "keys": [35, 45, 55], + "columns": { "야면석": [0.575, 0.88, 1.1] } + } + }, + "rows": [ + { + "seq": 1, + "name": "돌쌓기", + "spec": "", + "formula": "H*L*SQRT(1+N^2)", + "formula_text": "정면적 × √(1+n²) — 비탈면적", + "unit": "㎡", + "destination": "unit_price", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 2, + "name": "입적", + "spec": "", + "formula": "H*L*(IF(TOP_IN>0,TOP_IN,L3/100+0.3)+IF(BOT_IN>0,BOT_IN,IF(TOP_IN>0,TOP_IN,L3/100+0.3)+0.3*MAX(H-1,0)))/2", + "formula_text": "정면적 × 평균두께 · 상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H−1) (실무 구조물도 식, 두께를 넣으면 그 값)", + "unit": "㎥", + "destination": "reference", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 3, + "name": "고임돌", + "spec": "", + "formula": "A*LOOKUP_EXACT(고임돌표,L3,IF(돌종류='','참고자료',IF(계수기준='실무 관행','참고자료',돌종류)))", + "formula_text": "돌쌓기 × 고임돌 원단위(뒷길이·돌종류, 품셈 13-4-3)", + "refs": { "A": 1 }, + "unit": "㎥", + "destination": "material", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 4, + "name": "석적", + "spec": "", + "formula": "H*L*L3/100*0.77", + "formula_text": "정면적 × 뒷길이 × 0.77(채움률) — 소광리 시트에만 있는 줄", + "unit": "㎥", + "destination": "reference", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 5, + "name": "돌", + "spec": "{돌종류}", + "formula": "A*IF(돌종류='야면석·호박돌',LOOKUP_EXACT(돌중량표,L3,'야면석'),L3/100*0.77*2.65)", + "formula_text": "돌쌓기 × 돌중량 — 뒷길이 × 0.77 × 2.65(확정 5차, 잠정) · 야면석은 실무 관측표", + "refs": { "A": 1 }, + "unit": "ton", + "destination": "material", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 6, + "name": "막자갈", + "spec": "", + "formula": "(0.3+0.45)/2*H*L", + "formula_text": "(뒷채움 상 0.30 + 하 0.45) ÷ 2 × H × 연장 — 정본 여섯 탭 공통값", + "unit": "㎥", + "destination": "material", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 7, + "name": "채움콘크리트", + "spec": "{MPA}", + "formula": "A*LOOKUP_EXACT(채움표,L3,IF(돌종류='','참고자료',IF(계수기준='실무 관행','참고자료',돌종류)))", + "formula_text": "돌쌓기 × 채움 콘크리트 원단위(뒷길이·돌종류, 품셈 13-4-4 [주]①)", + "refs": { "A": 1 }, + "unit": "㎥", + "destination": "material", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 8, + "name": "모르터", + "spec": "", + "formula": "A*0.009", + "formula_text": "돌쌓기 × 0.009 ㎥/㎡ (줄눈)", + "refs": { "A": 1 }, + "unit": "㎥", + "destination": "unit_price", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 9, + "name": "물구멍관", + "spec": "Ø{HOLE_DIA}", + "formula": "A/HOLE_AREA*0.5", + "formula_text": "돌쌓기 ÷ 개소당 벽면적 × 0.5 m/개소 (실무 관측 2㎡ · 법은 2~3㎡당 1개소 이상)", + "refs": { "A": 1 }, + "unit": "m", + "destination": "material", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 10, + "name": "채집석", + "spec": "", + "when": "SUPPLY<>'구입'", + "formula": "S+W+R", + "formula_text": "석적 + 고임돌 + 막자갈 — 현장 채집분 · 여기서 빼지 않음(사토에서 한 번만 뺌)", + "refs": { "S": 4, "W": 3, "R": 6 }, + "unit": "㎥", + "destination": "haul_deduction", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 11, + "name": "버림콘크리트", + "spec": "", + "when": "IF(BLIND='안 넣음',0,IF(BLIND='안넣음',0,IF(BLIND='제외',0,1)))", + "formula": "IF(BOT_IN>0,BOT_IN,IF(TOP_IN>0,TOP_IN,L3/100+0.3)+0.3*MAX(H-1,0))*L*0.1", + "formula_text": "하단 길이(하부 두께) × 연장 × 두께 0.10m — KDS 44 90 00 · 폭은 잡석다짐 폭(KCS 34 50 05)", + "unit": "㎥", + "destination": "material", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 12, + "name": "터파기", + "spec": "", + "formula": "(IF(기초='기초유',0.5*0.9,IF(기초='기초버림',0.1*0.7,0))+H*((IF(TOP_IN>0,TOP_IN,L3/100+0.3)+IF(BOT_IN>0,BOT_IN,IF(TOP_IN>0,TOP_IN,L3/100+0.3)+0.3*MAX(H-1,0)))/2+0.2))*L", + "formula_text": "기초분(기초유 0.5×0.9 · 기초버림 0.1×0.7) + H × (평균두께 + 0.2) × 연장 — 실무 정본 식", + "unit": "㎥", + "destination": "earthwork", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 13, + "name": "되메우기", + "spec": "", + "formula": "(IF(기초='기초유',0.5,IF(기초='기초버림',0.1,0))+H)*0.2*L", + "formula_text": "(기초깊이 + H) × 0.2 × 연장", + "unit": "㎥", + "destination": "earthwork", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 14, + "name": "잔토처리", + "spec": "", + "formula": "E-B", + "formula_text": "터파기 − 되메우기", + "refs": { "E": 12, "B": 13 }, + "unit": "㎥", + "destination": "earthwork", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + }, + { + "seq": 15, + "name": "기초잡석", + "spec": "", + "when": "IF(BLIND='안 넣음',0,IF(BLIND='안넣음',0,IF(BLIND='제외',0,RUBBLE_T>0)))", + "formula": "B*(RUBBLE_T/0.1)", + "formula_text": "버림 × (잡석두께 ÷ 버림두께 0.1) — 폭이 같음(KCS 34 50 05) · 두께는 확정 3차 ②", + "refs": { "B": 11 }, + "unit": "㎥", + "destination": "unit_price", + "rounding": { "mode": "none", "digits": 0 }, + "source": "library" + } + ] +} diff --git a/resources/tester/test_b08_formula.py b/resources/tester/test_b08_formula.py index 3d0ea07c..734d89bd 100644 --- a/resources/tester/test_b08_formula.py +++ b/resources/tester/test_b08_formula.py @@ -27,6 +27,8 @@ pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 def _solve(rows: list[dict], vars: dict | None = None, tables: dict | None = None) -> list[dict]: + # 갈 곳은 계약상 필수(기본값 없음) — 값 시험에서는 「보여주기만」으로 채워 넣음. + rows = [{"destination": "reference", **row} for row in rows] result = evaluate_sheets([{"rows": rows, "vars": vars or {}, "tables": tables or {}}]) assert result is not None, "Node 풀이가 안 돌았다" return result[0] @@ -75,6 +77,18 @@ def test_반올림_갈래() -> None: assert row["amount"] == "0." + "3" * 30 +def test_엑셀_대응표_음수에서_갈리는_넷() -> None: + """명세 13장 대응표 — 양수에서는 같아 시험을 빠져나가던 자리(브레인 검산).""" + 갈래 = {"floor": "-3", "trunc": "-2", "ceil_away": "-3", "ceil": "-2", "round": "-3"} + for mode, expected in 갈래.items(): + assert _amount("-2.5", {"mode": mode, "digits": 0}) == expected, mode + assert _amount("-2.5", {"mode": "round_half_even", "digits": 0}) == "-2" + # 양수에서는 둘씩 같음 — 그래서 음수를 따로 봄. + assert _amount("2.5", {"mode": "trunc", "digits": 0}) == "2" + assert _amount("2.5", {"mode": "ceil_away", "digits": 0}) == "3" + assert "모르는 반올림" in _one("1", {"mode": "banker", "digits": 0})["error"] + + def test_함수() -> None: assert _amount("2*1*SQRT(1+0.3^2)", {"mode": "round", "digits": 3}) == "2.088" assert _amount("SQRT(2.25)") == "1.5" @@ -162,6 +176,57 @@ def test_참조는_반올림_뒤_값을_받는다() -> None: assert result[1]["amount"] == "45" +def test_when_거짓이면_줄이_안_서고_가리키면_막힌다() -> None: + """명세 13장 Ⓐ — 「안 섬」은 0 이 아님. 가리키는 뒷줄은 오류, 안 쓰면 영향 없음.""" + rows = [ + {"seq": 1, "name": "버림", "formula": "2", "when": "BLIND<>'안 넣음'"}, + {"seq": 2, "name": "잡석", "formula": "B*2", "refs": {"B": 1}}, + { + "seq": 3, + "name": "잡석_조건", + "formula": "B*2", + "refs": {"B": 1}, + "when": "BLIND<>'안 넣음'", + }, + {"seq": 4, "name": "무관", "formula": "7", "refs": {"B": 1}}, + ] + 넣음 = _solve(rows, {"BLIND": "넣음"}) + assert [row["amount"] for row in 넣음] == ["2", "4", "4", "7"] + 안넣음 = _solve(rows, {"BLIND": "안 넣음"}) + assert 안넣음[0]["skipped"] and 안넣음[0]["amount"] is None and 안넣음[0]["error"] is None + assert "조건이 거짓" in 안넣음[0]["reason"] + assert "앞 줄 1(버림) 안 섬" in 안넣음[1]["error"] + assert 안넣음[2]["skipped"] and 안넣음[2]["error"] is None # 조건이 먼저라 막히지 않음 + assert 안넣음[3]["amount"] == "7" # 이름을 안 쓰면 영향 없음 + + +def test_갈_곳이_빠지면_오류_기본값_없음() -> None: + """명세 13장 Ⓑ ⛔⛔ — 기본값이 빠진 줄을 조용히 사라지게 한 결함이 실재함.""" + rows = [ + {"seq": 1, "name": "빠짐", "formula": "1"}, + {"seq": 2, "name": "모름", "formula": "1", "destination": "somewhere"}, + {"seq": 3, "name": "맞음", "formula": "1", "destination": "material"}, + ] + result = evaluate_sheets([{"rows": rows}])[0] + assert "갈 곳(destination)이 없음" in result[0]["error"] + assert "모르는 갈 곳" in result[1]["error"] + assert result[2]["amount"] == "1" + + +def test_이름은_고정_규격이_제원을_따른다() -> None: + """명세 13장 Ⓒ — 조인 키(이름+규격)가 흔들리지 않게 이름은 고정.""" + rows = [ + {"seq": 1, "name": "돌", "spec": "{돌종류}", "formula": "1"}, + {"seq": 2, "name": "물구멍관", "spec": "Ø{DIA}", "formula": "1"}, + {"seq": 3, "name": "모름", "spec": "{없는칸}", "formula": "1"}, + ] + result = _solve(rows, {"돌종류": "깬돌", "DIA": 50}) + assert (result[0]["name"], result[0]["spec"]) == ("돌", "깬돌") + assert result[1]["spec"] == "Ø50" + assert "규격의 모르는 제원" in result[2]["error"] + assert _solve(rows[:1], {"돌종류": ""})[0]["spec"] == "" # 안 고르면 규격 빔 — 매칭 성공 아님 + + def test_고정형과_줄_제원() -> None: rows = [ {"seq": 1, "name": "박힌값", "amount": "2.088"}, diff --git a/resources/tester/test_b08_structure_template_parity.py b/resources/tester/test_b08_structure_template_parity.py new file mode 100644 index 00000000..55d8bea1 --- /dev/null +++ b/resources/tester/test_b08_structure_template_parity.py @@ -0,0 +1,136 @@ +"""찰쌓기 양식 = 지금 전개 — 값이 한 줄도 안 갈리는지 (2026-09-13, PLAN 3장 ② ③). + +판정 Ⓑ 「첫 양식 값은 지금 전개와 같게」. 양식(`resources/library_structure/masonry_wet.json`)을 +식 풀이기(TS → Node)로 풀어 낸 값이 `build_table`(돌쌓기 전개 + 기초잡석)과 같아야 함. +어긋나면 **양식이 틀린 것**. + +⚠ 전개는 부동소수, 양식은 분수 — 상대 1e-9 안이면 같은 값으로 봄. +⚠ 줄이 「안 서는」 경우(버림 안 넣음 · 채집석 구입)는 `when` 으로 — 안 선 줄은 전개에도 없어야 함. +⚠ 원문 「-」 칸은 일부러 다름(양식 `differs_from_engine`) — 여기서는 그 규격을 안 넣음. +""" + +from __future__ import annotations + +import itertools +import shutil +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( # noqa: E402 + load_template, + template_sheet, + template_vars, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + build_table, + face_slope_ratio, +) + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _structure(height: float, length: float, **options) -> dict: + return { + "structure_id": "s1", + "type_id": "masonry_wet", + "name": "돌쌓기(찰)", + "start_m": 100.0, + "end_m": 100.0 + length, + "length_m": length, + "height_m": height, + "options": {"height_m": height, "length_m": length, **options}, + } + + +CASES = [ + dict(height=h, length=10.0, back_len_cm=l3, stone_kind=kind, foundation=found) + for h, l3, kind, found in itertools.product( + (1.0, 1.5, 2.5, 3.5), + (35, 45, 55), + ("", "야면석·호박돌", "깬돌"), + ("기초유", "기초버림", ""), + ) +] + [ + # 사용자가 정한 칸 — 기울기 · 두께 · 실무 관행 계수 · 물구멍 면적 · 잡석 두께 · 짧은 연장 + dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"), + dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"), + dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"), + dict(height=2.0, length=10.0, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행"), + dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"), + dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3), + # 줄이 안 서는 갈래 — `when` + dict(height=2.0, length=10.0, blinding_concrete="안 넣음", foundation="기초유"), + dict(height=2.0, length=10.0, stone_supply="구입", stone_kind="깬돌"), + dict(height=2.0, length=10.0, blinding_concrete="안 넣음", stone_supply="구입"), + dict(height=2.0, length=10.0, rubble=0.0), + # 규격 칸 — 강도 · 물구멍 지름 + dict(height=2.0, length=10.0, fill_concrete_mpa="180", weep_hole_diameter_mm=75), +] + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: "-".join(f"{v}" for v in c.values())) +def test_양식_값이_전개와_같다(case: dict) -> None: + case = dict(case) + rubble = case.pop("rubble", None) + height = case.pop("height") + length = case.pop("length") + structure = _structure(height, length, **case) + + engine = build_table([structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble) + components = engine["structures"][0]["components"] + + template = load_template("masonry_wet") + assert template is not None + slope, _basis = face_slope_ratio(structure["options"], wet=True, height_m=height) + settings = {} if rubble is None else {"rubble_base_thickness_m": rubble} + values = template_vars(template, structure, slope, settings) + solved = evaluate_sheets([template_sheet(template, values)]) + assert solved is not None, "Node 풀이가 안 돌았다" + assert [row["error"] for row in solved[0]] == [None] * len(solved[0]), solved[0] + rows = [row for row in solved[0] if not row["skipped"]] + + assert len(rows) == len(components), ( + [r["name"] for r in rows], + [c["name"] for c in components], + ) + # ⚠ 알려진 차이 하나 — 「실무 관행」 계수를 고르면 전개가 돌종류까지 지워 돌 줄이 이름 「돌」· + # 계산식 무게로 섬(야면석을 골라도). 관행은 **계수 열만** 바꾸는 칸이라 전개 쪽 결함으로 보고 + # 양식은 종류를 지킴 — 양식 `differs_from_engine` · PLAN 3장에 판정 요청으로 적음. + practice_drops_kind = case.get("stone_coeff_basis") == "실무 관행" and case.get("stone_kind") + for row, component in zip(rows, components): + if row["name"] == "돌" and practice_drops_kind: + assert (component["name"], row["spec"]) == ("돌", case["stone_kind"]) + continue + if row["name"] == "돌": + # 명세 13장 Ⓒ — 양식은 이름 고정 + 규격에 종류, 전개는 종류를 이름으로 씀. + assert component["name"] == (row["spec"] or "돌") + else: + assert row["name"] == component["name"] + assert row["spec"] == component["spec"], (row, component) + assert template["rows"][row["seq"] - 1]["destination"] == component["destination"], row + assert float(row["amount"]) == pytest.approx(component["amount"], rel=1e-9, abs=1e-12), ( + row["name"], + row["amount"], + component["amount"], + ) + + +def test_실무_관측값은_대안_후보로_보인다() -> None: + """판정 Ⓑ — 지금 전개가 기본, 실무 관측(물구멍 2.5㎡)은 칸 옆 대안 후보.""" + hole = load_template("masonry_wet")["vars"]["HOLE_AREA"] + assert hole["default"] == 2 + assert any(item["value"] == 2.5 for item in hole["candidates"]) + + +def test_표는_키_오름차순() -> None: + """근사 LOOKUP 의 전제(명세 13장) — 정확 일치만 쓰더라도 표 모양은 한 규칙으로.""" + for name, table in load_template("masonry_wet")["tables"].items(): + assert table["keys"] == sorted(table["keys"]), name + for column, cells in table["columns"].items(): + assert len(cells) == len(table["keys"]), (name, column)