Files
Aislo/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py
T
eomsangdonandClaude Opus 5 fc40265dbe feat(B08): 구조물 원단위 전개식 — 치수에서 성분 물량
일감 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) <noreply@anthropic.com>
2026-09-07 23:25:34 +09:00

338 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (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),
}