feat(B09): 불도저 시공능력 공식 — 굴착기와 다른 식

운반 계열 착수. 품셈 원문 대조 결과 **셋이 서로 다른 식**임

```
굴착기  Q = 3600 ÷ Cm(초) × q · K · f · E        (8-1-4, 이미 구현)
불도저  Q = 60 ÷ cm(분) × (q₀ × e) · f · E        (8-2-1)  ← 이번
        cm = L/V1 + L/V2 + t,  t = 기어 변속 0.25분
덤프    Q = 60 ÷ cm × q · f · E,  q = T·L/γt      (8-2-8)  ← 다음
```
- ⚠ **밑수가 60(분)** 임 — 굴착기(3600초)와 섞으면 **60배 어긋남**. 주석에 못 박음
- ⚠ 속도가 「단」에 따라 다름(8-2-1 [주]) — 굴착·운반 1단 · **흐트러진 토사운반 2단** ·
  평탄 정지 3단. **임도 운반은 [주]② 가 가장 가까워 2단 잠정 채택**(TODO 미결)
- 표에 없는 규격은 **속도를 지어내지 않음**(짝 시험)

검산: 19톤·L=20m·V1 55·V2 70 → cm 0.8994분 · **Q 86.709㎥/hr** · 1㎥당 0.01153hr

검증: pytest 218 통과(신규 3)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 10:42:05 +09:00
co-authored by Claude Opus 5
parent dd23c9d04f
commit 3c5b561491
@@ -299,3 +299,83 @@ _RE_RATIO = re.compile(r"[(]\s*(\d+(?:\.\d+)?)\s*%\s*[)]")
def _ratio_of(cell: str) -> Decimal | None:
found = _RE_RATIO.search(str(cell))
return Decimal(found.group(1)) if found else None
# ── 불도저 — 굴착기와 **공식이 다르다** (건설품셈 8-2-1) ──────────────────────
#
# Q = 60 · q · f · E ÷ cm q = q₀ × e
# cm = L/V1 + L/V2 + t t = 기어 변속시간 0.25 분
#
# q₀ 거리를 고려하지 않은 삽날 용량(㎥) · e 운반거리계수 · L 운반거리(m)
# V1 전진속도(m/분) · V2 후진속도(m/분)
#
# ⚠ **굴착기 식(3600 ÷ Cm 초)과 섞지 말 것** — 불도저 cm 은 **분**이고 왕복 주행으로
# 만든다. 그래서 밑수가 60 이다. 섞으면 60배 어긋난다.
#
# ⚠ **속도는 「단」에 따라 다르다** (8-2-1 [주]) — 굴착·운반은 전진 1단·후진 1단,
# 흐트러진 토사운반은 2단, 평탄 정지는 3단. **임도 운반은 [주]② 「흐트러진 상태의
# 토사운반」이 가장 가까워 2단을 잠정 채택**한다. ⚠ TODO(미결) 사용자 확정 대기.
_DOZER_SPEEDS_2ND_GEAR = {
Decimal("4"): (Decimal(57), Decimal(85)),
Decimal("7"): (Decimal(67), Decimal(78)),
Decimal("10"): (Decimal(64), Decimal(75)),
Decimal("12"): (Decimal(55), Decimal(70)),
Decimal("13"): (Decimal(55), Decimal(70)),
Decimal("19"): (Decimal(55), Decimal(70)),
Decimal("32"): (Decimal(52), Decimal(58)),
}
#: 기어 변속시간 (분) — 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
@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}) × {self.volume_factor} × {self.efficiency}"
)
def dozer_hourly_output(factors: DozerFactors) -> Decimal:
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다."""
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
* factors.volume_factor
* factors.efficiency
)
if output <= 0:
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
return output
def dozer_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None:
"""그 규격의 전진·후진 속도(2단). 표에 없는 규격이면 `None` — 지어내지 않는다."""
return _DOZER_SPEEDS_2ND_GEAR.get(tonnage)