"""B09 원가계산 — **덤프트럭 운반** 시공능력 (산림사업 표준품셈 10-12 「2. 운반」 · 2026-09-14). 원문(고시 2025-82호 10-12-1·2·3 「2. 운반」)은 표가 아니라 **채워 넣는 식 서식**이라 공종 마스터에 안 실렸다. 값은 원문 본문에서 읽어 여기 한 곳에 둔다(식을 데이터로). Qt = T / γt × L 덤프 1대 적재토량(㎥, 흐트러진 상태) · T 15ton n = Qt / (q × K) 적재기계 싸이클 횟수 · q 버켓 0.7㎥ t1 = ㎝s × n / (60 × Es) 적재 대기(분) · ㎝s 20초 t2 = (D/V1 + D/V2) × 60 왕복(분) · D 운반거리 km · V1 5 · V2 6 km/hr ㎝t = t1 + t2 + t3 + t4 + t5 t3 적하 1.1 · t4 대기 0.9 · t5 덮개 0.5 (분) Q = 60 × Qt × f × E / ㎝t ㎥/hr (자연상태) · f = 1/L ⚠ **원문이 서로 어긋나는 자리 — 고른 값과 버린 값을 사유에 나란히 둔다**(2026-09-14 브레인 판정). 사용자가 뒤집을 수 있어야 한다. ① n 의 Qt — 원문 표기 「10/(0.7×K)」(셋 다 10) ↔ **계산값 T/γt×L**(토사 10.26·암절취 8.44· 발파암 10.16). 원문이 `q` 한 글자를 적재토량·버켓용량 두 뜻으로 써 10 은 토사 어림수 복사로 봄. ② 발파암 운반 줄에 E 누락 — **0.9**(덤프 작업효율 · 같은 절 토사·암절취 0.9). 빼면 Q 를 못 구함. ④ Es(적재기계 작업효율 · t1) ≠ E0(적재 식) — **운반 줄 값 그대로**(토사 0.85 · 암 0.35). ⚠ 운반거리 D 는 원문에 없다 — **설계자 값**(B08 유토곡선·사토장). 없으면 줄을 안 세우고 까닭(0 원 금지). ⚠ 적재(「1. 적재」 굴착기 Q1)는 **별도 줄**이다 — 이 식의 t1 은 덤프가 기다리는 시간이지 굴착기 품이 아님(흙깎기 9-3-2 ㎝ 20초·135° 는 깎아 옆에 둠 — 22초·180° 싣기와 다른 일). """ from __future__ import annotations import re from dataclasses import dataclass from decimal import Decimal from typing import Any from B09_Estimation import B09_Estimation_MachineProductivity_Data as data from B09_Estimation.B09_Estimation_MachineProductivity import fix2 #: 「[주] 장비는 덤프트럭(15ton)을 적용한다」 — 기계 카탈로그 덤프트럭 15. DUMP_TRUCK_CODE = "0602-0150" DUMP_PARENT = "FP-10-12" #: ⭐ 식이 쓰는 밑값은 **마스터**에 있다 — `resources/data_machine_productivity/`(2026-09-16 꺼냄). #: 식(Q·㎝t·n)은 값이 아니라 로직이라 여기 남는다. _HAUL = data.haul_params() _TRUCK_TON = _HAUL["truck_ton"] _BUCKET_M3 = _HAUL["bucket_m3"] _LOADER_CYCLE_SEC = _HAUL["loader_cycle_sec"] _V_LOADED_KMH = _HAUL["speed_loaded_kmh"] _V_EMPTY_KMH = _HAUL["speed_empty_kmh"] _T3_UNLOAD, _T4_WAIT, _T5_COVER = _HAUL["unload_min"], _HAUL["wait_min"], _HAUL["cover_min"] _SIXTY = Decimal(60) # 분↔시간 환산 — 값이 아니라 단위 @dataclass(frozen=True) class DumpMaterial: """10-12 절 하나의 재료 값 — 원문 「2. 운반」 줄.""" work_item_code: str label: str unit_weight: Decimal # γt ton/㎥ loose_factor: Decimal # L 토량환산계수 bucket_factor: Decimal # K loader_efficiency: Decimal # Es truck_efficiency: Decimal # E notes: tuple[str, ...] = () _COMMON_NOTE = ( "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)" ) _ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" DUMP_MATERIALS: dict[str, DumpMaterial] = { row["work_item_code"]: DumpMaterial( row["work_item_code"], row["label"], Decimal(row["unit_weight_ton_per_m3"]), Decimal(row["loose_factor"]), Decimal(row["bucket_factor"]), Decimal(row["loader_efficiency"]), Decimal(row["truck_efficiency"]), tuple(row.get("notes") or ()), ) for row in data.records("dump_material") } #: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°). #: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤ #: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**. LOADER_CODE = "0201-0070" _LOADING_CYCLE_SEC = _HAUL["loading_cycle_sec"] _LOADING_E0 = { row["work_item_code"]: Decimal(row["loading_efficiency"]) for row in data.records("dump_material") } #: 적재 E0 사유 — 마스터가 줄에 따로 들고 있음(운반 줄 사유와 섞지 않음). _LOADING_NOTES = { row["work_item_code"]: row.get("loading_note", "") for row in data.records("dump_material") } def loading_output(code: str) -> Decimal: """적재 Q1 (㎥/hr, 자연상태) — f·Q1 소수 2자리 확정(명세 7장).""" material = DUMP_MATERIALS[code] return fix2( Decimal(3600) * _BUCKET_M3 * material.bucket_factor * fix2(Decimal(1) / material.loose_factor) * _LOADING_E0[code] / _LOADING_CYCLE_SEC ) def loading_title_code(code: str) -> str: return f"B-{code}#적재" @dataclass(frozen=True) class DumpHaul: """덤프 운반 한 벌 — 재료 + 운반거리.""" material: DumpMaterial distance_m: Decimal @property def truck_load_m3(self) -> Decimal: return _TRUCK_TON / self.material.unit_weight * self.material.loose_factor @property def loader_cycles(self) -> Decimal: return self.truck_load_m3 / (_BUCKET_M3 * self.material.bucket_factor) @property def cycle_minutes(self) -> Decimal: wait = _LOADER_CYCLE_SEC * self.loader_cycles / (_SIXTY * self.material.loader_efficiency) km = self.distance_m / Decimal(1000) travel = (km / _V_LOADED_KMH + km / _V_EMPTY_KMH) * _SIXTY return wait + travel + _T3_UNLOAD + _T4_WAIT + _T5_COVER @property def volume_factor(self) -> Decimal: return fix2(Decimal(1) / self.material.loose_factor) @property def hourly_output(self) -> Decimal: """Q (㎥/hr) — f 와 Q 는 소수 2자리로 먼저 확정(명세 7장).""" return fix2( _SIXTY * self.truck_load_m3 * self.volume_factor * self.material.truck_efficiency / self.cycle_minutes ) @property def formula_text(self) -> str: m = self.material return ( f"Qt = 15/{m.unit_weight}×{m.loose_factor} = {self.truck_load_m3:.2f}㎥ · " f"n = {self.truck_load_m3:.2f}/(0.7×{m.bucket_factor}) = {self.loader_cycles:.2f}회 · " f"㎝t = {self.cycle_minutes:.2f}분(L={self.distance_m}m) · " f"Q = 60×{self.truck_load_m3:.2f}×{self.volume_factor}×{m.truck_efficiency}" f"/{self.cycle_minutes:.2f} = {self.hourly_output}㎥/hr" ) def dump_title_code(work_item_code: str, distance_m: Decimal) -> str: """운반거리별 갈래 — 사토장이 여럿이면 거리마다 호표가 갈림(STmate 도 그 모양).""" return f"B-{work_item_code}#L{distance_m.normalize():f}m" def distance_label(distance_m: Decimal) -> str: """보이는 운반거리 「L=137.13m」 — 소수 둘째 자리까지(㉰ 2026-09-14 · 가중평균이 14자리로 떴음). ⚠ **보이기만** 줄임 — 호표 열쇠(`dump_title_code`)·운반 식은 받은 거리 그대로 씀. """ shown = Decimal(distance_m).quantize(Decimal("0.01")).normalize() return f"L={shown:f}m" def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None: """거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B. 시간당 사용료가 안 섰으면 세우지 않고 까닭을 남긴다(0 원 일위대가 금지). """ from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle names = { str(node.get("work_item_code")): str(node.get("name") or "") for node in master.get("work_items", []) } # 적재 — 거리와 무관해 늘 세움(굴착기 0.7㎥ 사용료 층이 있으면). 운반과 한 벌이라 여기 둠. loader = f"X-{LOADER_CODE}" for code in DUMP_MATERIALS: title_code = loading_title_code(code) if loader not in build.book.titles or title_code in build.book.titles: continue output = loading_output(code) material = DUMP_MATERIALS[code] label = names.get(code) or material.label build.book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=f"{names.get(DUMP_PARENT) or '덤프운반'} {label} 적재", spec="굴착기(무한궤도) 0.7㎥", unit="㎥", ) ) build.book.add_output_detail( title_code, loader, Decimal(1) / output, f"Q1 = 3600×0.7×{material.bucket_factor}×{fix2(Decimal(1) / material.loose_factor)}" f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}", output=output, ) build.formula_variants[title_code] = "산림품셈 10-12 「1. 적재」 본문 식" if not distances_m: return hourly = f"X-{DUMP_TRUCK_CODE}" for code, material in DUMP_MATERIALS.items(): if hourly not in build.book.titles: build.component_gaps[code] = "덤프트럭(15ton) 시간당 사용료가 아직 안 섰습니다" continue for distance in distances_m: haul = DumpHaul(material, distance) title_code = dump_title_code(code, distance) if title_code in build.book.titles: continue build.book.add_title( PriceTitle( code=title_code, kind=PriceKind.UNIT_PRICE, name=f"{names.get(DUMP_PARENT) or '덤프운반'} " f"{names.get(code) or material.label}", spec=distance_label(distance), unit="㎥", ) ) build.book.add_output_detail( title_code, hourly, Decimal(1) / haul.hourly_output, haul.formula_text + " · " + " · ".join(material.notes), output=haul.hourly_output, ) build.formula_variants[title_code] = "산림품셈 10-12 「2. 운반」 본문 식 · 운반거리" def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]: """B08 인계에서 덤프 운반 거리(m) 갈래 — 거리가 없는 줄은 안 셈(그 줄은 「운반거리 미입력」).""" found = { str(Decimal(str(row["haul_distance_m"]))) for row in payload.get("work_items") or [] if str(row.get("work_item_code") or "").startswith(DUMP_PARENT) and row.get("haul_equipment") == "dump_truck" and row.get("haul_distance_m") not in (None, "") } return tuple(sorted(found, key=Decimal)) _RE_DUMP_CODE = re.compile(rf"^[BD]-{DUMP_PARENT}-0[1-3]#L(\d+(?:\.\d+)?)m$") #: 인계 「덤프 적재」 짝 줄의 수단 표지 — 운반 줄(`dump_truck`)과 갈라 잎 `#적재` 로 감. LOADING_EQUIPMENT = "dump_loading" def dump_distance_from_code(code: str) -> tuple[str, ...]: """호표 한 장을 여는 쪽 — 코드 `#L164.23m` 에서 거리를 되읽음(인계를 다시 안 셈).""" found = _RE_DUMP_CODE.match(code) return (found.group(1),) if found else () def dump_child_for(ground: str | None, edition: str) -> str | None: """인계 갈래(토사·리핑암·발파암) → 10-12 잎. 모르는 갈래는 `None` — 가까운 것을 안 고름.""" from common_util.common_util_aliases import alias_target, load_aliases wanted = str(ground or "").strip() if not wanted: return None alias = alias_target(load_aliases("variant"), wanted, DUMP_PARENT, edition) or wanted return next( (code for code, material in DUMP_MATERIALS.items() if material.label == alias), None )