Files
Aislo/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py
T
eomsangdonandClaude Opus 5 af23a5d209 feat(b09): 단가산출 Q 식 — f·Q 소수 2자리 확정 뒤 나눔 · D 줄 0.1원·머리 원 미만 절사
- fix2(사사오입 0.01) — 굴착기·도자·다짐 작업량, 암 평균 Q, 표 직접 작업량
- TRUNCATED_KINDS 에 PRICE_BASIS(D) 더함 — B 는 다음 차례
- 골든셋 ③ 단가산출근거 실무 6건 208 호표 전수 일치(제외 소계·% 줄·산근 참조·계약단가 읽기) · 회귀 3,545.5 → 3,545
- 검증 프로젝트 내역 본체 122,924,846 → 122,870,975 · 전체 시험 1606 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:26:45 +09:00

436 lines
17 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.
"""B09 원가계산 — **불도저** 시공능력 (건설품셈 8-2-1).
**굴착기(8-1-4)와 식이 다르다.** 한 파일에 두면 700 줄을 넘고, 무엇보다 두 식이
섞여 읽힌다 — 실제로 임도 불도저 표(FP-10-11)가 굴착기 식으로 읽혀 「K·Cm 없음」으로
잘못 진단되고 있었다(2026-09-08).
Q = 60 ÷ cm · (q₀ × e) · f · E cm = L/V1 + L/V2 + t
t = 기어 변속시간 0.25 분
q₀ 거리를 고려하지 않은 삽날 용량(㎥) · e 운반거리계수 · L 운반거리(m)
V1 전진속도(m/분) · V2 후진속도(m/분) · f 체적환산계수 · E 작업효율
⚠ **밑수가 60(분)이다.** 굴착기는 3600(초)이라 섞으면 60 배 어긋난다.
⚠ **표가 기종 이름을 안 적는다.** 임도 표는 `q₀`·`V1`·`V2` 만 주므로 8-2-1 표에서
규격을 **되짚는다** — 하나로 안 좁혀지면 안 고른다(지어내지 않는다).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import (
CycleFactors,
FactorGap,
ProductivityError,
_first_measure,
extract_cycle_factors,
fix2,
parse_measure,
)
_ZERO = Decimal(0)
#: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.**
#: {규격(ton): {단: (전진, 후진)}}
#: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은
#: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다.
#: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반
#: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다.
_DOZER_SPEEDS = {
"무한궤도": {
Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))},
Decimal("7"): {
1: (Decimal(43), Decimal(53)),
2: (Decimal(67), Decimal(78)),
3: (Decimal(92), Decimal(107)),
},
Decimal("10"): {
1: (Decimal(42), Decimal(50)),
2: (Decimal(64), Decimal(75)),
3: (Decimal(88), Decimal(105)),
},
Decimal("12"): {
1: (Decimal(40), Decimal(48)),
2: (Decimal(55), Decimal(70)),
3: (Decimal(75), Decimal(100)),
},
Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))},
Decimal("19"): {
1: (Decimal(40), Decimal(46)),
2: (Decimal(55), Decimal(70)),
3: (Decimal(75), Decimal(98)),
},
Decimal("32"): {
1: (Decimal(40), Decimal(43)),
2: (Decimal(52), Decimal(58)),
3: (Decimal(70), Decimal(78)),
},
},
"타이어": {
Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))},
Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))},
Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))},
},
}
#: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」
_DOZER_GEAR_SHIFT_MIN = Decimal("0.25")
_MINUTES_PER_HOUR = Decimal(60)
@dataclass(frozen=True)
class DozerFactors:
"""불도저 시공능력 계수 한 벌 (건설품셈 8-2-1)."""
work_item_code: str
blade_capacity_m3: Decimal # q₀
distance_factor: Decimal # e
volume_factor: Decimal # f
efficiency: Decimal # E
haul_distance_m: Decimal # L
forward_speed_m_min: Decimal # V1
reverse_speed_m_min: Decimal # V2
#: 표가 기종 이름을 안 적어 `q゚`·`V1`·`V2` 로 되짚은 결과(`resolve_dozer`).
machine_code: str = ""
machine_name: str = ""
@property
def cycle_minutes(self) -> Decimal:
"""cm = L/V1 + L/V2 + t — **분**이다."""
return (
self.haul_distance_m / self.forward_speed_m_min
+ self.haul_distance_m / self.reverse_speed_m_min
+ _DOZER_GEAR_SHIFT_MIN
)
@property
def formula_text(self) -> str:
return (
f"Q = 60 ÷ {self.cycle_minutes:.4f}× ({self.blade_capacity_m3} × "
f"{self.distance_factor}) × {fix2(self.volume_factor)} × {self.efficiency}"
+ (f" = {dozer_hourly_output(self)} ㎥/hr" if self.cycle_minutes > 0 else "")
)
def dozer_hourly_output(factors: DozerFactors) -> Decimal:
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** — f 와 Q 는 소수 2자리로 확정(명세 7장)."""
if factors.cycle_minutes <= 0:
raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.")
blade = factors.blade_capacity_m3 * factors.distance_factor
output = (
_MINUTES_PER_HOUR
/ factors.cycle_minutes
* blade
* fix2(factors.volume_factor)
* factors.efficiency
)
if output <= 0:
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
return fix2(output)
def dozer_speeds(
tonnage: Decimal, gear: int = 2, track: str = "무한궤도"
) -> tuple[Decimal, Decimal] | None:
"""그 규격·그 단의 전진·후진 속도. 표에 없으면 `None` — 지어내지 않는다."""
return _DOZER_SPEEDS.get(track, {}).get(tonnage, {}).get(gear)
#: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다.
#: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다.
_DOZER_BLADE_M3 = {
("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지
("무한궤도", Decimal("7")): Decimal("1.1"),
("무한궤도", Decimal("10")): Decimal("1.5"),
("무한궤도", Decimal("12")): Decimal("2.0"),
("무한궤도", Decimal("13")): Decimal("1.5"), # 습지
("무한궤도", Decimal("19")): Decimal("3.2"),
("무한궤도", Decimal("32")): Decimal("5.5"),
("타이어", Decimal("15")): Decimal("3.1"),
("타이어", Decimal("28")): Decimal("4.0"),
("타이어", Decimal("33")): Decimal("5.7"),
}
#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」.
_DOZER_WET_TONS = (Decimal("4"), Decimal("13"))
#: 표의 머리말. ⚠ **`e` 와 `E` 는 대소문자만 다르고 뜻이 전혀 다르다** —
#: `e` 는 운반거리계수, `E` 는 작업효율이다. 그래서 이 표는 **소문자로 내려 읽으면 안 된다**
#: (굴착기 쪽 `extract_cycle_factors` 는 내려 읽는다 — 그쪽엔 `e` 가 없어 안전하다).
_DOZER_SINGLE_KEYS = {
"L": "L",
"q0": "q0",
"q゚": "q0",
"q₀": "q0",
"e": "e",
"V1": "V1",
"V2": "V2",
"t": "t",
}
#: 갈래를 거느리는 머리말 — 「E | 토사 | 0.55」 아래에 「암석 | 0.25」가 딸려 온다.
_DOZER_GROUP_KEYS = ("E", "f")
_RE_GEAR = re.compile(r"(\d+)\s*단")
def resolve_dozer(
blade_m3: Decimal,
forward: Decimal,
reverse: Decimal,
gear: int = 2,
) -> tuple[str, str] | None:
"""삽날 용량과 속도로 **불도저 기종을 되짚는다**.
임도 품셈 표는 기종 이름을 안 적고 `q゚`·`V1`·`V2` 만 준다. 그 셋이 8-2-1 표에서
한 규격만 가리킬 때 그 기종으로 본다 — **둘 이상이면 안 고른다**(지어내지 않는다).
q゚ 3.2㎥ + 55/70 m/분(2단) → 불도저(무한궤도) 19 톤
⚠ **단(gear)까지 맞아야 한다** — 같은 삽날이라도 단이 다르면 다른 규격을 가리킨다.
"""
candidates = []
for (track, tonnage), blade in _DOZER_BLADE_M3.items():
if blade != blade_m3:
continue
if dozer_speeds(tonnage, gear, track) == (forward, reverse):
candidates.append((track, tonnage))
if len(candidates) != 1:
return None
track, tonnage = candidates[0]
if track == "무한궤도" and tonnage in _DOZER_WET_TONS:
wanted = "습지 불도저"
else:
wanted = f"불도저({track})"
for code, machine in load_machine_catalog().machines.items():
if machine.name == wanted and parse_measure(machine.specification) == tonnage:
return code, f"{machine.name} {machine.specification}"
return None
def dozer_machine_hours_per_unit(factors: DozerFactors) -> Decimal:
"""수량 1단위당 불도저 소요시간(hr)."""
return Decimal(1) / dozer_hourly_output(factors)
def extract_dozer_factors(
work_item_code: str,
table: dict[str, Any],
) -> dict[str, DozerFactors] | FactorGap | None:
"""불도저 표 하나에서 **갈래별** 계수를 뽑는다 (품셈 8-2-1).
돌려주는 것 — 불도저 표가 아니면 `None`, 계수가 모자라면 `FactorGap`,
다 서면 `{갈래: DozerFactors}` (토사·파쇄암·발파암처럼 `f` 갈래마다 한 벌).
⚠ **딸린 줄은 한 칸 왼쪽으로 밀려 온다** — 머리 줄은 「f | 토사 | 1/1.30」이고
다음 줄은 「파쇄암 | 1/1.35」다. 자리를 그대로 읽으면 갈래가 통째로 빠진다.
"""
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
if not rows:
return None
single: dict[str, Decimal] = {}
groups: dict[str, dict[str, Decimal]] = {"E": {}, "f": {}}
gears: dict[str, int] = {}
current: str | None = None
for cells in rows:
if not cells or not cells[0]:
continue
head = cells[0]
if head in _DOZER_SINGLE_KEYS:
current = None
key = _DOZER_SINGLE_KEYS[head]
value = _first_measure(cells[1:])
if value is not None:
single[key] = value
found = _RE_GEAR.search(" ".join(cells[1:]))
if found:
gears[key] = int(found.group(1))
elif head in _DOZER_GROUP_KEYS:
current = head
label = cells[1] if len(cells) > 1 else ""
value = _first_measure(cells[2:])
if value is None:
# 갈래가 없는 표 — 「f | 1/1.3」처럼 값이 바로 붙는다. 갈래 이름은 빈 문자열.
value = _measure_with_adjustment(label)
label = ""
if value is not None:
groups[head][_normalize_label(label)] = value
elif current is not None:
# 딸린 줄 — 「파쇄암 | 1/1.35」. 값이 없으면 갈래 줄이 아니다.
value = _first_measure(cells[1:])
if value is not None:
groups[current][_normalize_label(head)] = value
if not groups["f"] or "q0" not in single or "V1" not in single:
return None # 불도저 표가 아니다
missing = [key for key in ("L", "q0", "e", "V1", "V2", "t") if key not in single]
if not groups["E"]:
missing.append("E(작업효율)")
if missing:
return FactorGap(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
missing=tuple(missing),
note="불도저 표(8-2-1)인데 계수가 모자랍니다.",
)
gear = gears.get("V1", gears.get("V2", 2))
machine = resolve_dozer(single["q0"], single["V1"], single["V2"], gear)
if machine is None:
return FactorGap(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
missing=("기계",),
note=(
f"삽날 {single['q0']}㎥ · {single['V1']}/{single['V2']}m/분({gear}단) "
"으로는 규격이 하나로 안 좁혀집니다."
),
)
built: dict[str, DozerFactors] = {}
for label, volume_factor in groups["f"].items():
efficiency = _dozer_efficiency(groups["E"], label)
if efficiency is None:
continue # 그 갈래의 작업효율이 없다 — 가운데값을 지어내지 않는다
built[label] = DozerFactors(
work_item_code=work_item_code,
blade_capacity_m3=single["q0"],
distance_factor=single["e"],
volume_factor=volume_factor,
efficiency=efficiency,
haul_distance_m=single["L"],
forward_speed_m_min=single["V1"],
reverse_speed_m_min=single["V2"],
machine_code=machine[0],
machine_name=machine[1],
)
return built or FactorGap(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
missing=("E(갈래별 작업효율)",),
note="`f` 갈래에 맞는 작업효율을 못 골랐습니다.",
)
_RE_ADJUSTED = re.compile(r"^\(?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\)?$")
def _measure_with_adjustment(cell: str) -> Decimal | None:
"""계수 한 칸. **「(0.55-0.1)」 같은 보정식도 읽는다.**
8-2-1 [주]④⑤ 가 「정지작업을 겸하면 0.1 을, 터파기는 0.05 를 뺀 값」이라 적어,
임도 표가 뺄셈을 그대로 적어 온다(비다듬기·정지 9-17-2). ⚠ **범위(「0.550.45」)와
헷갈리면 안 된다** — 범위는 확정값이 아니라 `parse_measure` 가 `None` 을 낸다.
여기서 읽는 것은 **하이픈 뺄셈 한 가지**뿐이다.
"""
found = _RE_ADJUSTED.match(" ".join(str(cell).split()))
if found:
return Decimal(found.group(1)) - Decimal(found.group(2))
return parse_measure(cell)
def _normalize_label(text: str) -> str:
return "".join(str(text).split())
def _dozer_efficiency(efficiencies: dict[str, Decimal], label: str) -> Decimal | None:
"""그 갈래의 작업효율 `E`.
⚠ 표가 `f` 는 「토사·파쇄암·발파암」으로 잘게 주고 `E` 는 「토사·암석」으로 굵게 준다.
그래서 **암 갈래는 「암석」 줄을 쓴다** — 그 표가 암을 한 값으로 묶어 준 것이다.
"""
if label in efficiencies:
return efficiencies[label]
if "암" in label:
for key, value in efficiencies.items():
if "암" in key:
return value
return efficiencies.get("토사") if len(efficiencies) == 1 else None
def dozer_variants(node: dict[str, Any]) -> list[str]:
"""그 공종이 불도저 공식으로 세울 수 있는 갈래 이름들. 아니면 빈 목록."""
labels: list[str] = []
for table in node.get("tables", []):
found = extract_dozer_factors(str(node.get("work_item_code", "")), table)
if isinstance(found, dict):
labels.extend(label for label in found if label not in labels)
return labels
def formula_machine_codes(master: dict[str, Any]) -> set[str]:
"""**공식표에서만 드러나는 기종 코드.**
⚠ 자원 축에는 안 나온다 — 표가 기계를 「줄」로 안 적고 계수로만 적기 때문이다.
이 코드를 시간당 사용료 층에 안 넣으면, 공식은 다 서 놓고 **붙일 사용료가 없어**
빈 일위대가가 남는다(2026-09-08 불도저 운반에서 실제로 그랬다).
"""
codes: set[str] = set()
for node in master.get("work_items", []):
code = str(node.get("work_item_code", ""))
for table in node.get("tables", []):
factors = extract_cycle_factors(code, table)
if isinstance(factors, CycleFactors):
codes.add(factors.machine_code)
found = extract_dozer_factors(code, table)
if isinstance(found, dict):
codes.update(f.machine_code for f in found.values() if f.machine_code)
return codes
def attach_dozer_share(
book: Any,
factor_gaps: dict[str, FactorGap],
master: dict[str, Any],
work_item_code: str,
title_code: str,
variant: str,
) -> Decimal:
"""불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0.
⚠ 굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에
붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을
때만** 굴착기 쪽으로 간다.
"""
node = next(
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
None,
)
if node is None:
return _ZERO
wanted = _normalize_label(variant)
for table in node.get("tables", []):
found = extract_dozer_factors(work_item_code, table)
if isinstance(found, FactorGap):
factor_gaps[work_item_code] = found
continue
if not isinstance(found, dict):
continue
factors = found.get(wanted)
if factors is None:
continue
hourly_code = f"X-{factors.machine_code}"
if hourly_code not in book.titles:
factor_gaps[work_item_code] = FactorGap(
work_item_code=work_item_code,
pum_table_id=factors.work_item_code,
missing=("시간당 사용료",),
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
book.add_output_detail(
title_code, hourly_code, dozer_machine_hours_per_unit(factors), factors.formula_text
)
return Decimal(100)
return _ZERO