"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15). 식을 발명하지 않는다 — 실무 원본을 옮긴다 울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로 남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값 (돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**. 그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다. 치수 정본은 하나다 (PLAN 8-6 ② 필수 조건) 전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다. 자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. ⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙 ㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**. 시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다. 실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②). `verify_no_mix_components()` 가 이 규칙을 코드로 지킨다. ㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7). · **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종 (`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다 (울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 — 그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다. ⚠ 공제 규칙 (품셈 1-2-1 원문) 「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리, 리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.** 치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼 실제로 비어 있는 자리뿐이다. """ from __future__ import annotations import json import math from pathlib import Path from functools import lru_cache from dataclasses import dataclass, field from typing import Any, Iterable from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( BASIS_DERIVED, BASIS_OBSERVED, ObservedUnitTable, billing_of, expand_observed, load_observed_table, ) from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status from common_util.common_util_quantity_spread import spread_by_unit # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── # 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. # ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). #: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. #: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. #: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, } DEFAULT_BACK_LENGTH_CM = 45 #: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. #: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. #: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 #: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" STONE_KIND_PREFIX = "stone_kind_" STONE_KIND_OPTION = "stone_kind" @lru_cache(maxsize=1) def load_stone_kind_table() -> dict[str, Any]: """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" folder = STONE_KIND_DIR if not folder.is_dir(): return {} files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) if not files: return {} try: return json.loads(files[-1].read_text(encoding="utf-8")) except (OSError, ValueError): return {} def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. """ table = load_stone_kind_table() key = str(back_cm) kind = str(options.get(STONE_KIND_OPTION) or "").strip() if not table: return {}, "" if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") if not kind: fallback = table.get("fallback") or {} return { "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), "kind": "", }, str(fallback.get("message") or "") if kind not in (table.get("kinds") or []): return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) note = "" if wedge is None: note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" return { "wedge_stone_m3_per_m2": wedge, "fill_concrete_m3_per_m2": fill, "backfill_ratio": ratio, "kind": kind, }, note # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 # (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가 # **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고 # 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다. # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 "thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께 "thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H) "thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H) # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) "excavation_extra_m": 0.2, # 터파기 폭 여유 "backfill_thickness_m": 0.2, # 되메우기 두께 } # 성분이 어디로 가는가 — 이중계상을 막는 표시. # `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) # `material` = 자재총괄로 감(할증은 거기서 한 번만) # `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) #: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭). #: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고 #: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. #: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 #: 폭과 동일**하게 한다」. #: ⚠ 우리에게 **잡석다짐 폭이 아직 없다**(기초잡석은 품셈에 공종만 있고 두께·폭이 없음). #: 그래서 **터파기 폭(평균두께 + 여유 0.2m)을 잠정으로** 쓰고 근거 문구에 그 사실을 적는다. #: 잡석다짐 폭이 정해지면 이 한 줄만 바꾸면 된다. #: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). #: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 #: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 #: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다. #: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} #: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 #: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). #: #: 채집석 공제는 사토에서 한 번만 뺀다. #: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, #: 빼는 자리는 유토곡선의 사토뿐이다 — #: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. #: #: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번 #: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. COLLECTED_STONE_KEY = "collected_stone_deduction_m3" BLINDING_THICKNESS_M = 0.10 #: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). #: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") DESTINATION = { "터파기": "earthwork", "되메우기": "earthwork", "잔토처리": "earthwork", "돌쌓기": "unit_price", "돌붙임": "unit_price", "깬돌": "material", "야면석": "material", "고임돌": "material", "막자갈": "material", "콘크리트": "unit_price", "채움콘크리트": "unit_price", "버림콘크리트": "unit_price", "모르터": "unit_price", "거푸집": "unit_price", "물구멍관": "material", # ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은 # `material` 만 모으므로 여기 섞이지 않는다. "채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) } # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. # ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 # 오탐된다 — 개발 중 실제로 걸렸던 자리다. MIX_COMPONENTS = frozenset( {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} ) @dataclass(slots=True) class Component: """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" name: str unit: str amount: float destination: str basis: str = "" # ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). # 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다. basis_kind: str = BASIS_DERIVED source: str = "" @dataclass(slots=True) class StructureQuantity: """구조물 하나의 원단위 전개 결과.""" structure_id: str | None type_id: str name: str length_m: float = 0.0 height_m: float = 0.0 # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). start_m: float | None = None end_m: float | None = None # 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다. # 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나). options: dict[str, Any] = field(default_factory=dict) components: list[Component] = field(default_factory=list) notes: list[str] = field(default_factory=list) #: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인 #: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증). #: 비어 있으면 종전대로 「m · 연장」으로 선다. billing_unit: str = "" billing_quantity: float = 0.0 def is_collected_stone(options: dict[str, Any]) -> bool: """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" for key in STONE_SUPPLY_KEYS: raw = options.get(key) if raw is None: continue if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED: return False return True def wants_blinding(options: dict[str, Any]) -> bool: """버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭). 「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다. """ for key in BLINDING_OPTION_KEYS: raw = options.get(key) if raw is None: continue text = str(raw).strip().lower() if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}: return False return True def _blinding_component(base_width_m: float, length_m: float, options: dict[str, Any]): """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`.""" if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0: return None return Component( "버림콘크리트", "㎥", base_width_m * length_m * BLINDING_THICKNESS_M, DESTINATION["버림콘크리트"], f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" " · 두께는 KDS 44 90 00(100㎜) · ⚠ 폭은 잡석다짐 폭(KCS 34 50 05)이라야 하나" " 그 값이 아직 없어 **터파기 폭을 잠정**으로 씀", ) #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. #: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") def _back_length(options: dict[str, Any]) -> int: """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — 접으면 다른 규격 계수가 조용히 돈다. """ for key in BACK_LENGTH_KEYS: raw = options.get(key) if raw is None: continue try: value = int(float(raw)) except (TypeError, ValueError): continue # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. return value return DEFAULT_BACK_LENGTH_CM def _num(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback #: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). #: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 #: 「뒷길이 45㎝」 계수로 돌던 자리다. BOULDER_DIAMETERS = ("40~60", "60~80", "80~100") #: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. #: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다. BOULDER_MASONRY = { # ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것. # 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다. "sheet_check_factor_at_0_3": 1.04, "excavation_extra_m": 0.2, # 터파기 폭 여유 "backfill_thickness_m": 0.2, # 되메우기 두께 } def _boulder_diameter(options: dict[str, Any]) -> str | None: """저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**.""" raw = options.get("stone_cm") text = str(raw).strip() if raw is not None else "" return text if text in BOULDER_DIAMETERS else None def boulder_masonry( height_m: float, length_m: float, options: dict[str, Any] ) -> tuple[list[Component], list[str]]: """큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다. ⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. 돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을 내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다. ⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) — 따로 세우면 이중계상이다. ⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다. 두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정한다. 미확보로 둔다. """ notes: list[str] = [] if height_m <= 0 or length_m <= 0: return [], ["높이·연장이 없어 전개하지 않음"] diameter = _boulder_diameter(options) if diameter is None: return [], [ "큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" ] slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3 constants = BOULDER_MASONRY face_area = height_m * length_m # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). masonry_area = face_area * math.hypot(1.0, slope_ratio) components = [ Component( "큰돌쌓기", "㎡", masonry_area, DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)", ) ] # 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다. # ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로 # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. upper_cm = float(str(diameter).split("~")[-1]) thickness = upper_cm / 100.0 # 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」. blinding = _blinding_component(thickness + constants["excavation_extra_m"], length_m, options) if blinding is not None: components.append(blinding) excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m backfill = height_m * constants["backfill_thickness_m"] * length_m components.extend( [ Component( "터파기", "㎥", excavation, DESTINATION["터파기"], f"높이 × (벽두께 {thickness:g}m + 0.2) × 연장 · ⚠ 큰돌쌓기 터파기 폭 규정이" " 품셈에 없어 돌쌓기 방식을 준용", ), Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), Component( "잔토처리", "㎥", excavation - backfill, DESTINATION["잔토처리"], "터파기 − 되메우기", ), ] ) notes.append( "재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 " "돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음" ) notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음") notes.append( "뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 " "직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보" ) notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름") return components, notes def stone_masonry( height_m: float, length_m: float, options: dict[str, Any], wet: bool ) -> tuple[list[Component], list[str]]: """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. 실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3): 정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245 터파기 1.55 · 되메우기 0.30 · 잔토 1.25 """ notes: list[str] = [] if height_m <= 0 or length_m <= 0: return [], ["높이·연장이 없어 전개하지 않음"] back_cm = _back_length(options) if back_cm not in STONE_BACK_LENGTH_TABLE: # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. return [], [ f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" ] table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 # 멈추면 이미 저장된 프로젝트가 통째로 빈다. picked, kind_note = stone_coefficients(options, back_cm) if kind_note: notes.append(kind_note) # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). if picked.get("kind"): for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): table[key] = picked.get(key) else: for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): if picked.get(key) is not None: table[key] = picked[key] #: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다. #: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서 #: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다. #: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음). backfill_share = picked.get("backfill_ratio") body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0 kind_label = str(picked.get("kind") or "") # ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다. # 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다. # (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.) # **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** / # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3) constants = STONE_MASONRY face_area = height_m * length_m # 정면적 # 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.** # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). masonry_area = face_area * math.hypot(1.0, slope_ratio) thickness = ( (constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m) + (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m) ) / 2.0 volume = face_area * thickness # 입적 components = [ Component( "돌쌓기", "㎡", masonry_area, DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio}²) — 비탈면적", ), ] # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. if table["wedge_stone_m3_per_m2"] is None: notes.append( f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" + (f" · {kind_label}" if kind_label else "") + " 칸이 「-」입니다" ) else: components.append( Component( "고임돌", "㎥", masonry_area * _num(table["wedge_stone_m3_per_m2"]), DESTINATION["고임돌"], f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + (f" · {kind_label}" if kind_label else ""), ) ) stone_ton = table["stone_ton_per_m2"] if stone_ton is None: # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함") else: components.append( Component( "야면석", "ton", masonry_area * stone_ton, DESTINATION["야면석"], f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", ) ) # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3). wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌 stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적 rubble = volume - (stone_body + wedge) if rubble > 0: components.append( Component( "막자갈", "㎥", rubble, DESTINATION["막자갈"], f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌)" + (f" · {kind_label}" if kind_label else ""), ) ) if wet: components.append( Component( "채움콘크리트", "㎥", masonry_area * _num(table["fill_concrete_m3_per_m2"]), DESTINATION["채움콘크리트"], f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", ) ) components.append( Component( "모르터", "㎥", masonry_area * constants["mortar_m3_per_m2"], DESTINATION["모르터"], f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)", ) ) # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). # 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m. # ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고 # 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다. # ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에 # 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면 # 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다 # (`structure_unit_observed` 의 `double_count_rules`). # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. components.append( Component( "물구멍관", "m", masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], DESTINATION["물구멍관"], # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·" " 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)", ) ) # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** if is_collected_stone(options): collected = max(stone_body + wedge + max(rubble, 0.0), 0.0) if collected > 0: components.append( Component( "채집석", "㎥", collected, DESTINATION["채집석"], f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈" f" {max(rubble, 0.0):.3f} ㎥ · 현장 채집분 ·" " ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", ) ) # 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다. base_width = thickness + constants["excavation_extra_m"] blinding = _blinding_component(base_width, length_m, options) if blinding is not None: components.append(blinding) # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m backfill = height_m * constants["backfill_thickness_m"] * length_m components.extend( [ Component( "터파기", "㎥", excavation, DESTINATION["터파기"], "높이 × (평균두께+0.2) × 연장" ), Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), Component( "잔토처리", "㎥", excavation - backfill, DESTINATION["잔토처리"], "터파기 − 되메우기", ), ] ) return components, notes # 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). # ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. # 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면 # 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다. OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { "retaining_wall": ("form", "height_m"), "ford_pavement": ("thickness_cm",), # 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다. "pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"), } EXPANDERS = { "masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True), "masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False), "boulder_masonry": lambda h, l, o: boulder_masonry(h, l, o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). # 앞서 `stone_masonry(dry)` 로 전개하고 있었는데, 그러면 직경 60~80㎝ 짜리가 # **「뒷길이 45㎝」 계수로 돌아 조용히 틀린 값**이 나온다(고임돌 0.15·야면석 0.88 …). # ⚠ 값이 나오기는 하므로 어떤 시험도 안 잡던 자리다 — 「값이 있기는 하니 안 보이는」 그것. # 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.** } #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. EXPANDER_WITHHELD: dict[str, str] = {} #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = { # (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리) "pipe": (("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),), } def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" rows: list[dict[str, Any]] = [] options = structure.get("options") or {} for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()): if not options.get(gate_key): continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 rows.append( { **structure, "structure_id": f"{structure.get('structure_id')}-{type_id}", "type_id": type_id, "attachment_of": structure.get("structure_id"), "attachment_parent_type": structure.get("type_id"), "attachment_label": label, } ) return rows def _observed_components( type_id: str, structure: dict[str, Any], observed: ObservedUnitTable | None, ) -> tuple[list[dict[str, Any]], list[str]]: """관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다.""" keys = OBSERVED_SPEC_KEYS.get(type_id) if keys is None: return [], [] options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: from B08_Quantity.B08_Quantity_Wording import option_missing return [], [option_missing(keys[0], type_id)] return expand_observed(type_id, spec, structure, observed) def _observed_billing( type_id: str, structure: dict[str, Any], observed: ObservedUnitTable | None, ) -> tuple[str, float] | None: """관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다.""" keys = OBSERVED_SPEC_KEYS.get(type_id) if keys is None: return None options = structure.get("options") or {} spec = {key: options[key] for key in keys if options.get(key) is not None} if not spec: return None return billing_of(type_id, spec, structure, observed) def expand( structure: dict[str, Any], names: dict[str, str] | None = None, observed: ObservedUnitTable | None = None, ) -> StructureQuantity: """구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지).""" type_id = str(structure.get("type_id") or "") options = structure.get("options") or {} start = _num(structure.get("start_m")) end = _num(structure.get("end_m")) length = _num(options.get("length_m")) or abs(end - start) height = _num(options.get("height_m")) label = (names or {}).get(type_id, type_id) if structure.get("attachment_label"): # 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다. parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "") label = f"{parent or label} · {structure['attachment_label']}".strip(" ·") result = StructureQuantity( structure_id=structure.get("structure_id"), type_id=type_id, name=label, length_m=length, height_m=height, start_m=start if structure.get("start_m") is not None else None, end_m=end if structure.get("end_m") is not None else None, options=dict(options), ) withheld = EXPANDER_WITHHELD.get(type_id) if withheld: result.notes.append(f"전개식 미확보 — {withheld}") return result expander = EXPANDERS.get(type_id) if expander is None: # 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류). components, notes = _observed_components(type_id, structure, observed) if components or notes: result.components = [Component(**item) for item in components] result.notes.extend(notes) billing = _observed_billing(type_id, structure, observed) if billing is not None: result.billing_unit, result.billing_quantity = billing return result from B08_Quantity.B08_Quantity_Wording import type_label result.notes.append( f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다" ) return result result.components, notes = expander(height, length, options) result.notes.extend(notes) return result def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]: """⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어). 시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다. 실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다. """ found: list[str] = [] for item in quantities: for component in item.components: if component.name.strip() in MIX_COMPONENTS: found.append(f"{item.name}({item.type_id}) 의 '{component.name}'") return found def build_table( structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.""" observed = load_observed_table() # 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다. expanded_inputs: list[dict[str, Any]] = [] for item in structures: expanded_inputs.append(item) expanded_inputs.extend(attachments_of(item)) quantities = [expand(item, names, observed) for item in expanded_inputs] violations = verify_no_mix_components(quantities) totals: dict[str, dict[str, Any]] = {} for item in quantities: for component in item.components: key = f"{component.name}|{component.unit}" entry = totals.setdefault( key, { "name": component.name, "unit": component.unit, "amount": 0.0, "destination": component.destination, }, ) entry["amount"] += component.amount payload_structures = [ { "structure_id": item.structure_id, "type_id": item.type_id, "name": item.name, "length_m": item.length_m, "height_m": item.height_m, "start_m": item.start_m, "end_m": item.end_m, # 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다. "billing_unit": item.billing_unit, "billing_quantity": item.billing_quantity, # 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다. "options": item.options, "notes": item.notes, "components": [ { "name": component.name, "unit": component.unit, "amount": component.amount, "destination": component.destination, "basis": component.basis, "basis_kind": component.basis_kind, "source": component.source, } for component in item.components ], } for item in quantities ] # 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫). formwork_notes, formwork_missing = annotate_formwork(payload_structures) return { "structures": payload_structures, "formwork_notes": formwork_notes, "formwork_reuse_missing": formwork_missing, # 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다. "shoring": shoring_status(), # ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. # 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지. "pending_choices": (observed.pending_choices or {}).get("items") or [], "totals": sorted(totals.values(), key=lambda entry: entry["name"]), # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). "surcharge_applied": False, "mix_components_found": violations, "structure_count": len(quantities), # ⚠ 사토에서 뺄 밑수 — **양수**로 낸다. 빼는 것은 유토곡선(랩탑 메인) 몫이다. COLLECTED_STONE_KEY: round( sum( component.amount for item in quantities for component in item.components if component.name == "채집석" ), 3, ), "amount_spread": spread_by_unit(totals.values(), value_key="amount"), }