From fc40265dbe82d600ad9c6935077a9e7e3c867dfa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 23:25:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=9B=90=EB=8B=A8=EC=9C=84=20=EC=A0=84=EA=B0=9C=EC=8B=9D=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=98=EC=88=98=EC=97=90=EC=84=9C=20=EC=84=B1?= =?UTF-8?q?=EB=B6=84=20=EB=AC=BC=EB=9F=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일감 6 엔진. PLAN 8-6·8-8·8-15. 식을 발명하지 않음 — 실무 원본을 옮김 울진 설계원본 5. 구조도(기번3).xlsx 에 구조물 31종 계산식이 살아 있는 수식으로 남아 있음(8-15). 그 식을 옮기되 식 안에 상수로 박혀 있던 값(돌 뒷길이 0.45 · 고임돌 0.15 · 돌중량 0.88 등)은 **계수표로 뺌** — 뒷길이가 바뀌어도 식을 안 고침. 실무 방식의 약점을 여기서 고친 자리. 치수 정본은 하나 — structures.json 의 type_id·options 를 읽어 계산. 자기 치수표를 따로 들지 않음(도면은 H=1.5 인데 수량은 옛 치수로 도는 사고 방지). ⚠⚠ 이중계상 셋을 코드로 막음 ㉢ 배합을 분해하지 않음 — 콘크리트 ㎥·모르터 ㎥ 에서 멈춤. 시멘트·모래로 쪼개는 것은 B09 일위대가 몫이고 양쪽이 쪼개면 시멘트가 두 배가 됨. verify_no_mix_components() 가 산출물에 배합 성분이 나타나면 알림. ⚠ 정확히 같은 이름으로만 봄 — 부분문자열로 재니 「막자갈」(뒤채움 재료)이 배합 「자갈」로 오탐됐음. 개발 중 실제로 걸린 자리라 시험에도 박음. ㉠ 할증을 안 붙임 — surcharge_applied=False 로 응답에 못 박음. 할증은 자재총괄 한 곳뿐. · 성분마다 destination 을 담 — earthwork(토공 대분류로 합산) / material(자재총괄) / unit_price(일위대가 재료비). 울진 토적집계 D12~D14 가 구조물 터파기 1,248/30㎥ · 되메우기 739㎥ 를 개별 줄이 아니라 토공으로 합산한 것이 근거. ⚠ 공제 규칙을 주석에 원문째 — 품셈 1-2-1 「물구멍·이음줄눈·모따기·철근은 공제하지 않음」. 치수를 곧이곧대로 빼면 실무값과 어긋남. 빼는 것은 관 통과 단면처럼 실제로 비어 있는 자리뿐. 원본에 없는 칸은 지어내지 않음 — 뒷길이 60㎝ 돌중량이 원본 표에 비어 있어 야면석을 내지 않고 그 사실을 notes 로 알림(8-8 ㉮). ⚠ 실무값과 0.012 차이 — 우리 쪽이 맞음 터파기 1.5375 vs 실무 1.55. 평균두께가 0.825 인데 실무 시트는 표기값 0.83 으로 다시 계산해 1.545 가 됨. 품셈 1-2-2 는 표기 규칙이고 계산은 전정밀(8-16). 맞추려고 식을 비틀지 않고 허용오차를 그 차이만큼 둠. 검증 — 17건 통과. 배합 금지·할증 금지·행선지 표시·계수표 교체·빈 칸 미생성을 각각 못 박음. 실데이터(route 150 의 돌쌓기(찰) H=2.5·L=10)로 확인 — 성분 10개, 배합 위반 0. 전체 회귀 450 passed (실패 1건은 B05 코리도 기존 깨짐). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_UnitQuantity.py diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py new file mode 100644 index 00000000..6a107634 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -0,0 +1,337 @@ +"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (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 math +from dataclasses import dataclass, field +from typing import Any, Iterable + +# ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── +# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. +# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). +STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | 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}, +} +DEFAULT_BACK_LENGTH_CM = 45 + +# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. +STONE_MASONRY = { + "face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (비탈 기울기 몫) + "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) + "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 + "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 가 배합을 분해) +DESTINATION = { + "터파기": "earthwork", + "되메우기": "earthwork", + "잔토처리": "earthwork", + "돌쌓기": "unit_price", + "돌붙임": "unit_price", + "깬돌": "material", + "야면석": "material", + "고임돌": "material", + "막자갈": "material", + "콘크리트": "unit_price", + "채움콘크리트": "unit_price", + "모르터": "unit_price", + "거푸집": "unit_price", + "물구멍": "material", +} + +# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. +# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 +# 오탐된다 — 개발 중 실제로 걸렸던 자리다. +MIX_COMPONENTS = frozenset( + {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} +) + + +@dataclass(slots=True) +class Component: + """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" + + name: str + unit: str + amount: float + destination: str + basis: 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 + components: list[Component] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +def _back_length(options: dict[str, Any]) -> int: + raw = options.get("stone_back_length_cm") + if isinstance(raw, (int, float)) and int(raw) in STONE_BACK_LENGTH_TABLE: + return int(raw) + return DEFAULT_BACK_LENGTH_CM + + +def _num(value: Any, fallback: float = 0.0) -> float: + return float(value) if isinstance(value, (int, float)) else fallback + + +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) + table = STONE_BACK_LENGTH_TABLE[back_cm] + 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²) — 기울어진 만큼 길어진다. + slope_area = face_area * math.hypot(1.0, slope_ratio) + masonry_area = slope_area * constants["face_to_slope_factor"] + 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["돌쌓기"], "비탈면적 × 1.04"), + Component( + "고임돌", + "㎥", + masonry_area * _num(table["wedge_stone_m3_per_m2"]), + DESTINATION["고임돌"], + f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", + ), + ] + + 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}㎝)", + ) + ) + + # 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로. + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) + rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge) + if rubble > 0: + components.append( + Component( + "막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)" + ) + ) + + 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. + components.append( + Component( + "물구멍", + "m", + masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], + DESTINATION["물구멍"], + "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소", + ) + ) + + # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). + 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 + + +# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). +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: stone_masonry(h, l, o, wet=False), +} + + +def expand(structure: dict[str, Any], names: dict[str, str] | 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")) + result = StructureQuantity( + structure_id=structure.get("structure_id"), + type_id=type_id, + name=(names or {}).get(type_id, type_id), + length_m=length, + height_m=height, + ) + expander = EXPANDERS.get(type_id) + if expander is None: + result.notes.append(f"'{type_id}' 전개식이 아직 없음 — 물량을 내지 않음") + 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 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.""" + quantities = [expand(item, names) for item in structures] + 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 + + return { + "structures": [ + { + "structure_id": item.structure_id, + "type_id": item.type_id, + "name": item.name, + "length_m": item.length_m, + "height_m": item.height_m, + "notes": item.notes, + "components": [ + { + "name": component.name, + "unit": component.unit, + "amount": component.amount, + "destination": component.destination, + "basis": component.basis, + } + for component in item.components + ], + } + for item in quantities + ], + "totals": sorted(totals.values(), key=lambda entry: entry["name"]), + # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). + "surcharge_applied": False, + "mix_components_found": violations, + "structure_count": len(quantities), + }