From 1375d8c29095bcc9e4e43dd4ce45760d37721f9d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 17:23:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=ED=83=AD=EC=97=90=20=EC=96=91=EC=8B=9D=20=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=EC=8B=A4=EC=9D=8C=20=E2=80=94=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=20=EA=B8=B0=EC=B4=88=C2=B7=EC=8B=9D=C2=B7=EA=B0=88=20=EA=B3=B3?= =?UTF-8?q?=C2=B7=EC=95=88=20=EC=84=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 양식이 있는 종류(찰쌓기)는 장마다 양식으로 줄을 다시 세움(L=1 m당) — 식·설명·반올림· 갈 곳·출처·안 섬 까닭을 줄에 실음. 양식 없는 종류는 지금 전개 그대로(고정형 모양) - 화면 표: 산출 근거 밑에 식 한 줄 · 갈 곳 칸 · 안 선 줄은 「안 섬」과 까닭 - 전개 결함 둘 고침(브레인 판정): 「실무 관행」 계수가 돌종류를 지우던 것 · 기초잡석이 서는데 「두께가 없어 안 섬」 사유가 함께 뜨던 것 - 검증 프로젝트 찰쌓기 15줄 — 양식 m당×10 = 원단위 탭 합계 전부 일치 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureSheet.py | 2 + .../B08_Quantity_Engine_StructureTemplate.py | 80 +++++++++++++++++++ .../B08_Quantity_Engine_UnitQuantity.py | 23 +++--- .../B08_Quantity_Router_StructureSheet.py | 6 +- .../B08_Quantity_UI_StructureSheet.ts | 58 ++++++++++++-- resources/library_structure/masonry_wet.json | 3 +- resources/tester/test_b08_blinding.py | 26 +++++- .../tester/test_b08_structure_sheet_router.py | 30 +++++++ .../test_b08_structure_template_parity.py | 8 +- resources/tester/test_b08_unit_quantity.py | 22 +++++ 10 files changed, 227 insertions(+), 31 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py index 610e7cc1..c858db78 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py @@ -178,6 +178,8 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]: "unit_amount": _unit_amount(amount, quantity), "amount": amount, "unit": component.get("unit") or "", + # 갈 곳 — 이중계상 경계를 줄마다 보임(명세 13장 Ⓑ). 전개가 이미 싣고 옴. + "destination": component.get("destination") or "", # 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용. "basis_kind": component.get("basis_kind") or "", "source": component.get("source") or "", diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index f5028a64..515e7ddf 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -72,3 +72,83 @@ def template_sheet(template: dict[str, Any], values: dict[str, Any]) -> dict[str "vars": values, "tables": template.get("tables") or {}, } + + +#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례). +_PER_LENGTH_UNITS = frozenset({"m"}) + + +def _library_rows( + template: dict[str, Any], solved: list[dict[str, Any]], billing: float +) -> list[dict[str, Any]]: + """풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬까지 실음(명세 13장).""" + by_seq = {row["seq"]: row for row in template.get("rows") or []} + rows: list[dict[str, Any]] = [] + for result in solved: + source = by_seq.get(result["seq"]) or {} + unit_amount = float(result["amount"]) if result.get("amount") is not None else None + rows.append( + { + "no": result["seq"], + "name": result["name"], + "spec": result.get("spec") or "", + "basis": source.get("formula_text") or "", + "formula": source.get("formula") or "", + "unit_amount": unit_amount, + "amount": None if unit_amount is None else unit_amount * billing, + "unit": source.get("unit") or "", + "basis_kind": "derived", + "source": source.get("source") or "library", + "destination": source.get("destination") or "", + "rounding": source.get("rounding"), + "skipped": bool(result.get("skipped")), + "reason": result.get("reason") or "", + "error": result.get("error") or "", + } + ) + return rows + + +def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = None) -> None: + """구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침). + + ⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장). + ⚠ Node 풀이가 안 돌면 전개 줄을 두고 **그 사실을 장 사유에 적음** — 조용히 넘기지 않음. + ⚠ 장은 제원 조합 하나라 **L=1(m당)** 으로 풂 — 연장은 제원이 아니고 모든 줄이 L 에 비례. + """ + from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets + from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of + + targets: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for sheet in payload.get("sheets") or []: + template = load_template(str(sheet.get("type_id") or "")) + if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS: + for row in sheet.get("rows") or []: + row.setdefault("formula", "") + row["source"] = row.get("source") or "auto" + continue + structure = { + "height_m": sheet.get("height_m"), + "length_m": 1.0, + "options": sheet.get("options"), + } + values = template_vars(template, structure, slope_of(sheet)[0], settings) + targets.append((sheet, template_sheet(template, values))) + + if not targets: + return + solved = evaluate_sheets([body for _sheet, body in targets]) + for index, (sheet, _body) in enumerate(targets): + template = load_template(str(sheet.get("type_id") or "")) or {} + if solved is None: + sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음") + continue + members = sheet.get("members") or [] + billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0 + sheet["rows"] = _library_rows(template, solved[index], billing) + sheet["unpriced_rows"] = [ + row["name"] + for row in sheet["rows"] + if row["unit_amount"] is None and not row["skipped"] + ] + sheet["library_item"] = {"type_id": template.get("type_id"), "name": template.get("name")} diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index b92aaac4..53e8c44e 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -600,14 +600,6 @@ def _blinding_component( ) -#: 기초잡석(품셈 12-25)이 아직 못 서는 까닭 — **폭은 있고 두께가 없다.** -RUBBLE_BASE_BLOCKED = ( - "기초잡석(12-25) 물량이 안 섬 — 폭은 하단 길이 {width:.2f}m 로 섰으나 **두께가 원문에 " - "없음**(품셈 12-25 는 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 " - "다른 구조물 값을 옮겨 쓰지 않음" -) - - #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. @@ -906,6 +898,13 @@ def stone_masonry( # ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로 # 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다. kind_label = str(picked.get("kind") or "") + # ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만 + # 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 이름·무게는 + # **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 「돌」). + chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip() + stone_kind_name = ( + chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label + ) # ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도 # 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이 # 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.) @@ -986,10 +985,10 @@ def stone_masonry( # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, # 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다). stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2( - back_cm, kind_label, table["stone_ton_per_m2"] + back_cm, stone_kind_name, table["stone_ton_per_m2"] ) weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else "" - if not kind_label: + if not stone_kind_name: notes.append( "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " "야면석이면 계산식이 안 맞아 관측표로 갈립니다" @@ -1124,8 +1123,8 @@ def stone_masonry( ) if blinding is not None: components.append(blinding) - - notes.append(RUBBLE_BASE_BLOCKED.format(width=base_width)) + # ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」). + # 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측). # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). components.extend( diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index 52a28d64..f2d19561 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -52,19 +52,23 @@ def project_structure_sheets( 모듈 맨 위에서 부르면 맞물린다. """ from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B08_Quantity.B08_Quantity_Router_Material import _collect_structures from common_util.common_util_project_settings import quantity_settings structures, names, skipped = _collect_structures(project_root) + settings = quantity_settings(project_root) unit_table = build_unit_table( structures, names, section_modes, ground_types, - quantity_settings(project_root).get("rubble_base_thickness_m"), + settings.get("rubble_base_thickness_m"), ) payload = build_standard_sheets(unit_table, section_modes) + # 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장). + apply_templates(payload, settings) # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. payload["skipped_structures"] = skipped return payload diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index 1e8d1e18..108fa636 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -26,10 +26,53 @@ export interface StructureSheetRow { basis: string; /** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */ unit_amount: number | null; - amount: number; + amount: number | null; unit: string; basis_kind: string; source: string; + /** 양식 줄만 — 기계가 푸는 식(명세 13장). 비면 고정형(지금 전개). */ + formula?: string; + destination?: string; + rounding?: { mode: string; digits: number } | null; + /** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */ + skipped?: boolean; + reason?: string; + error?: string; +} + +const DESTINATION_LABELS: Record = { + earthwork: "토공집계", + material: "자재총괄", + unit_price: "일위대가", + reference: "보여주기", + haul_deduction: "운반 공제", +}; + +const ROUNDING_LABELS: Record = { + floor: "내림(INT)", + trunc: "버림", + round: "반올림", + ceil_away: "올림", + ceil: "위로", + round_half_even: "짝수 반올림", +}; + +/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */ +function amountText(row: StructureSheetRow): string { + if (row.skipped) return "안 섬"; + return num(row.unit_amount, 3); +} + +/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */ +function noteText(row: StructureSheetRow): string { + if (row.skipped) return row.reason ?? ""; + if (row.error) return `⚠ ${row.error}`; + const origin = + row.source === "library" ? "양식" : row.basis_kind === "observed" ? "실무 관측" : "치수 전개"; + const mode = row.rounding?.mode; + return mode && mode !== "none" + ? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리` + : origin; } export interface StructureSheet extends StandardSheetSpec { @@ -67,7 +110,8 @@ const CSS = ` .b08-sheet__tabs { flex-wrap: wrap; } .b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; } /* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */ -.b08-sheet__rows td:nth-child(3) { white-space: normal; min-width: 16rem; } +.b08-sheet__rows td:nth-child(3) { white-space: pre-line; min-width: 16rem; } +.b08-sheet__rows td:nth-child(7) { white-space: normal; max-width: 18rem; } @media (max-width: 900px) { .b08-sheet { flex-direction: column; } .b08-sheet__aside { flex-basis: auto; width: 100%; } @@ -177,15 +221,17 @@ function sheetBody(sheet: StructureSheet): HTMLElement { } else { main.append( table( - ["공종", "규격", "산출 근거", "수량", "단위", "비고"], + ["공종", "규격", "산출 근거", "수량", "단위", "갈 곳", "비고"], sheet.rows.map((row) => [ row.name, row.spec, // 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임. - row.basis.replace(/\*\*/g, ""), - num(row.unit_amount, 3), + // 양식 줄은 **식을 버리지 않고** 설명 밑에 함께 보임(명세 13장 지킬 것 ④). + row.basis.replace(/\*\*/g, "") + (row.formula ? `\n= ${row.formula}` : ""), + amountText(row), row.unit, - row.basis_kind === "observed" ? "실무 관측" : "치수 전개", + DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? "", + noteText(row), ]), "b08-sheet__rows", ), diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json index 90081391..375832bc 100644 --- a/resources/library_structure/masonry_wet.json +++ b/resources/library_structure/masonry_wet.json @@ -7,8 +7,7 @@ "note": "구조물도 양식형 항목 첫 벌(PLAN 3장 ② · 명세 13장 식 칸 계약). 값은 지금 전개(`B08_Quantity_Engine_UnitQuantity.stone_masonry` + 기초잡석)와 같게 둠(판정 Ⓑ). 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않고 제원 vars 와 표 한 벌로 수량이 다시 남(명세 16장).", "differs_from_engine": [ "원문 「-」 칸(야면석 75·깬돌 25·견치돌 25/30 고임돌)에서 전개는 고임돌 줄을 안 세우고 채집석에 0 을 더함. 이 양식은 고임돌 줄이 「표 칸이 비어 있음」 오류로 서고 채집석도 막힘 — 명세 13장 Ⓐ(안 선 줄을 0 으로 치지 않음)을 따른 것", - "돌 줄 이름 — 전개는 돌종류 이름(야면석·호박돌…/돌), 양식은 이름 「돌」 고정 + spec 에 종류(명세 13장 Ⓒ)", - "「실무 관행」 계수를 고르면 전개는 돌종류까지 지워 돌 줄을 「돌」·계산식 무게로 세움(야면석을 골라도). 관행은 계수 열만 바꾸는 칸이라 전개 쪽 결함으로 보고 양식은 종류를 지킴 — 판정 요청" + "돌 줄 이름 — 전개는 돌종류 이름(야면석·호박돌…/돌), 양식은 이름 「돌」 고정 + spec 에 종류(명세 13장 Ⓒ)" ], "vars": { "H": { "label": "높이(m)", "source": "height_m" }, diff --git a/resources/tester/test_b08_blinding.py b/resources/tester/test_b08_blinding.py index 5e873125..38a7f360 100644 --- a/resources/tester/test_b08_blinding.py +++ b/resources/tester/test_b08_blinding.py @@ -65,12 +65,32 @@ def test_폭은_하단_길이에서_온다() -> None: assert "잠정" not in got.basis -def test_기초잡석은_두께가_없어_안_선다() -> None: - """⚠ 폭은 생겼지만 **두께가 원문에 없다** — 지어내지 않고 사유로 드러낸다.""" +def test_기초잡석은_계상하고_안_선다는_사유를_안_남긴다() -> None: + """ⓘ 2026-09-13 바뀜 — 옛 시험은 「두께가 원문에 없어 안 선다」를 굳혔음. + + 확정 3차 ② 로 두께 0.2 가 서고 `build_table` 이 기초잡석 줄을 세우는데, 전개는 여전히 + 「안 섬」 사유를 붙여 **구조물도에 줄과 사유가 함께 떠 서로 어긋났음**(화면 실측). + 브레인 판정(PLAN 10장 「기초잡석 계상함」)대로 사유를 걷고 줄이 서는지 봄. + """ + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table + _, notes = stone_masonry( 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, wet=True ) - assert any("기초잡석(12-25)" in n and "두께가 원문에 없음" in n for n in notes) + assert not any("기초잡석(12-25)" in n and "안 섬" in n for n in notes) + table = build_table( + [ + { + "structure_id": "s1", + "type_id": "masonry_wet", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, + } + ] + ) + names = [c["name"] for c in table["structures"][0]["components"]] + assert "기초잡석" in names def test_큰돌쌓기에도_선다() -> None: diff --git a/resources/tester/test_b08_structure_sheet_router.py b/resources/tester/test_b08_structure_sheet_router.py index 6243eaa9..c551f7e1 100644 --- a/resources/tester/test_b08_structure_sheet_router.py +++ b/resources/tester/test_b08_structure_sheet_router.py @@ -82,6 +82,36 @@ def test_조회가_장을_낸다(client: TestClient) -> None: assert all("spec" in row for row in sheet["rows"]) +def test_양식이_있는_종류는_줄마다_식을_싣는다(client: TestClient) -> None: + """PLAN 3장 ④ · 명세 13장 — 찰쌓기는 양식으로 줄이 서고 식·설명·갈 곳·출처가 실림.""" + sheet = _sheet(client) + assert sheet["library_item"]["type_id"] == "masonry_wet" + rows = {row["name"]: row for row in sheet["rows"]} + 돌쌓기 = rows["돌쌓기"] + assert 돌쌓기["formula"] == "H*L*SQRT(1+N^2)" + assert "비탈면적" in 돌쌓기["basis"] + assert 돌쌓기["source"] == "library" and 돌쌓기["destination"] == "unit_price" + assert all(row["destination"] for row in sheet["rows"]) # 갈 곳 빈 줄 없음 + # 돌 줄은 이름 고정 — 종류를 안 골라 규격이 빔(매칭 성공 아님, 명세 13장 Ⓒ). + assert rows["돌"]["spec"] == "" + # m당 값 — 제원(H=2.5·뒷길이 45·1:0.3)으로 다시 셈한 값. + assert 돌쌓기["unit_amount"] == pytest.approx(2.5 * (1 + 0.3**2) ** 0.5) + + +def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None: + """버림을 「안 넣음」으로 저장하면 버림·기초잡석이 0 이 아니라 「안 섬」으로 옴.""" + sheet = _sheet(client) + saved = client.put( + f"{SHEETS}/spec", + json={"sheet_key": sheet["key"], "base_revision": 1, "blinding_concrete": "안 넣음"}, + ) + assert saved.status_code == 200, saved.text + rows = {row["name"]: row for row in _sheet(client)["rows"]} + for name in ("버림콘크리트", "기초잡석"): + assert rows[name]["skipped"] is True and rows[name]["unit_amount"] is None, rows[name] + assert rows[name]["reason"], rows[name] + + def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None: 기본 = _unit_amount(_sheet(client), "기초잡석") (project / "project_settings.json").write_text( diff --git a/resources/tester/test_b08_structure_template_parity.py b/resources/tester/test_b08_structure_template_parity.py index 55d8bea1..25d91c29 100644 --- a/resources/tester/test_b08_structure_template_parity.py +++ b/resources/tester/test_b08_structure_template_parity.py @@ -99,14 +99,8 @@ def test_양식_값이_전개와_같다(case: dict) -> None: [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") + # ⓘ 「실무 관행」 계수가 돌종류를 지우던 전개 결함은 2026-09-13 고침(브레인 판정) — 차이 없음. 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 "돌") diff --git a/resources/tester/test_b08_unit_quantity.py b/resources/tester/test_b08_unit_quantity.py index d5b25d16..050f6bcc 100644 --- a/resources/tester/test_b08_unit_quantity.py +++ b/resources/tester/test_b08_unit_quantity.py @@ -271,6 +271,28 @@ def test_물구멍_잠정값이_근거에_적힐것() -> None: assert "2~3㎡" in basis # 법이 정한 범위 +def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None: + """2026-09-13 브레인 판정 — 계수 열 고르기와 돌종류는 다른 축. + + 옛 전개는 「실무 관행」을 고르면 야면석이 「돌」·계산식 무게(0.918)로 조용히 섰음. + 이제 계수만 참고자료 열(고임돌 0.15)로 가고, 돌은 야면석 이름·관측 무게(0.88)를 지킴. + """ + result = expand( + 돌쌓기찰( + height=2.0, + length=10.0, + back_len_cm=45, + stone_kind="야면석·호박돌", + stone_coeff_basis="실무 관행", + face_slope_ratio=0.3, + ) + ) + 돌쌓기 = 성분(result, "돌쌓기").amount + assert 성분(result, "야면석·호박돌").amount == pytest.approx(돌쌓기 * 0.88) + assert 성분(result, "고임돌").amount == pytest.approx(돌쌓기 * 0.15) # 관행 열 + assert not any(c.name == "돌" for c in result.components) + + def test_물구멍관_길이는_평균두께() -> None: """2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = 평균두께(옛 상수 0.5m 아님).