From 6aa8001780fddeee19d44745cedb41735411488d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 10:32:37 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(B09):=20=E3=80=8C=EC=84=B1=EB=B6=84?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=A0=EC=A0=B8=20=EB=AA=BB=20=EC=84=B8=EC=9B=80?= =?UTF-8?q?=E3=80=8D=EC=9D=84=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=8C=E3=80=8D=EA=B3=BC=20=EA=B0=88?= =?UTF-8?q?=EB=9D=BC=20=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창이 물음 — 종단배수 셋이 같은 모양(밑수 1 m)인데 맹암거만 금액이 서고 산마루측구·소단측구는 안 서는 까닭 **표 성격이 달랐음** — 맹암거(F0345)는 수량이 실제로 적힌 소요량표라 자원 4줄이 붙어 25,492.1원/m 로 섬. 나머지 둘은 **수량 칸이 비어 있고 참조로만** 적힘 ``` FP-12-09-02 철근가공조립 | ton | (빈칸) | 「철근가공조립(간단)의 30%」 FP-12-09-03 거 푸 집 | 회 | (빈칸) ``` ⇒ 값을 지어낼 수 없어 **금액을 안 만드는 것이 맞음**(지금 방침 그대로) - 다만 화면 사유가 **「일위대가 없음」으로 뭉뚱그려지고** 있었음 — 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다름**. 사유를 갈라 적음: 「성분이 빠져 단가를 못 세웠습니다 — 철근가공조립 줄을 못 풀었습니다」 - `UnitPriceBuild.component_gaps` 로 자원 축의 사유를 화면까지 나름 - 짝 시험 — 성분 빠진 쪽은 금액 없음 + 사유 표시, **맹암거는 그대로 금액이 섬** 검증: pytest 213 통과(신규 1). 맹암거 40m × 25,492.1 = 1,019,685원 — 메인 값과 일치 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities_Rows.py | 12 ++++++++++-- B09_Estimation/B09_Estimation_UnitPrice.py | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index d01c0d65..f3544b42 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -208,8 +208,16 @@ def _leaf_row( row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" else: - row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." - reason = "일위대가 없음" + # ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**. + # 뭉뚱그리면 사용자가 무엇을 기다려야 하는지 못 읽는다(2026-09-08 산마루측구: + # 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리). + gap = unit_prices.component_gaps.get(node.code) + if gap: + row.note = f"성분이 빠져 단가를 못 세웠습니다 — {gap}" + reason = f"성분 미확보 — {gap}" + else: + row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." + reason = "일위대가 없음" result.missing.append( { "name": row.name, diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 19976501..41943249 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -78,6 +78,10 @@ class UnitPriceBuild: skipped: list[str] = field(default_factory=list) #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). incomplete_machines: list[str] = field(default_factory=list) + #: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구. + #: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져 + #: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구). + component_gaps: dict[str, str] = field(default_factory=dict) #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). partial_ratio: dict[str, Decimal] = field(default_factory=dict) #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. @@ -315,6 +319,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} build = UnitPriceBuild() + build.component_gaps = dict(axis.partial_items) missing_basis = load_basis_missing() wages = load_operator_wages() _add_labor_titles(build.book, wages) From dd23c9d04f73a194303435af04306aa66c4a0251 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 10:37:50 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(B09):=20=EA=B8=B0=EA=B3=84=EB=A7=8C=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B3=B5=EC=A2=85=EC=9D=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=8B=9D=EB=A7=8C=EC=9C=BC=EB=A1=9C=20=EC=84=9C=EA=B2=8C=20?= =?UTF-8?q?=ED=95=A8=20=E2=80=94=20=EC=B8=B5=EB=94=B0=EA=B8=B0=20=EC=82=B4?= =?UTF-8?q?=EC=95=84=EB=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「미판정 77건」을 훑다 나온 것 — 그중 일부는 **미판정이 아니라 시공능력 공식표**였음 ``` F0287 9-18 층따기 굴착기 0.7㎥ · K 0.9 · f 1/1.3 · E 0.7 · Cm 22 F0328 11-4 쇄석·혼합석 부설 같은 모양 ``` - ⚠ **자원 줄이 하나도 없으면 조립 반복문이 그 공종을 아예 안 보고 있었음** — 표에 인력 줄이 없는 기계 전용 공종이 그 자리. 공식이 온전하면 세우게 고침 - 층따기 **1,550.9원/㎡** · 쇄석 부설 **3,549.5원/㎡** 로 섬 (일위대가 203 → 206) - 내역서(2f940d8a) 합계 **2,149,464 → 6,252,172원** — 층따기 2,645.21㎡ × 1,550.9 = 4,102,708 이 실제로 붙음 - ⚠ 짝 시험 — 자원도 공식도 없으면 **여전히 안 섬**(0 원 일위대가 금지). 그 문이 넓으면 빈 제목이 무더기로 서고 화면이 「단가가 있다」고 거짓말함 검증: pytest 215 통과(신규 2) Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_UnitPrice.py | 30 +++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 41943249..043e61c4 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -333,6 +333,19 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: for row in axis.rows: by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row) + # ⚠ **자원 줄이 하나도 없어도 공식이 온전하면 세운다.** 기계만 쓰는 공종(층따기 9-18 · + # 쇄석 부설 11-4)은 표에 인력 줄이 없어 자원 축이 비고, 그러면 아래 반복문이 그 공종을 + # 아예 안 본다 — 「미판정」으로 남아 있던 것이 실은 **시공능력 공식표**였다 + # (2026-09-08 미판정 77건을 훑다 발견). + for node in master.get("work_items", []): + code = node.get("work_item_code") + if not code or (code, "") in by_item: + continue + for table in node.get("tables", []): + if isinstance(extract_cycle_factors(code, table), CycleFactors): + by_item[(code, "")] = [] + break + for (work_item_code, variant), rows in sorted(by_item.items()): # 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로** # (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해 @@ -349,7 +362,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: for row in rows ] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] - if not attachable: + if not attachable and not _has_full_formula(master, work_item_code): + # 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지). build.skipped.append(work_item_code) continue @@ -416,6 +430,20 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: return build +def _has_full_formula(master: dict, work_item_code: str) -> bool: + """그 공종에 **온전한 시공능력 공식**이 있는가 (기계만 쓰는 공종용).""" + 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 False + return any( + isinstance(extract_cycle_factors(work_item_code, table), CycleFactors) + for table in node.get("tables", []) + ) + + def _share_of(row) -> Decimal: """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" ratio = getattr(row, "group_ratio_pct", None) From 3c5b561491e9a616f1d57e7ce810385c9b1f2f24 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 10:42:05 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B09):=20=EB=B6=88=EB=8F=84=EC=A0=80=20?= =?UTF-8?q?=EC=8B=9C=EA=B3=B5=EB=8A=A5=EB=A0=A5=20=EA=B3=B5=EC=8B=9D=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=B4=EC=B0=A9=EA=B8=B0=EC=99=80=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운반 계열 착수. 품셈 원문 대조 결과 **셋이 서로 다른 식**임 ``` 굴착기 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) --- .../B09_Estimation_MachineProductivity.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index e7a41d35..79bc2e56 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -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) From b97eb042b6abfefc7e04f5bd827c3c08e8a5ded4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 11:47:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(B09):=20=EB=B6=88=EB=8F=84=EC=A0=80=20?= =?UTF-8?q?=ED=91=9C(8-2-1)=20=EC=9D=BD=EC=96=B4=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=EB=B3=84=20=EB=8B=A8=EA=B0=80=20=EC=84=B8=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FP-10-11 불도저 운반이 굴착기 식(8-1-4)으로 읽혀 「K·Cm 없음」으로 잘못 진단되던 것을 8-2-1 식으로 읽게 함. 갈래 셋(토사·파쇄암·발파암) 각각 섬 (1,439.7 / 3,289.3 / 3,959.4 원/㎥). - 표가 기종 이름을 안 적어 삽날 용량 q゚ 3.2㎥ + 속도 55/70(2단)으로 불도저(무한궤도) 19톤을 되짚음. 하나로 안 좁혀지면 안 고름. - 공식표에만 나오는 기종을 시간당 사용료 층에 넣음 — 안 넣으면 공식이 다 서고도 붙을 사용료가 없어 빈 일위대가가 남았음. - 아무것도 안 붙은 제목은 걷어 냄 (화면에서 「조립 불가」로 터지던 자리). - 시험 6건 추가, 224건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_MachineProductivity.py | 296 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 31 +- 2 files changed, 325 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 79bc2e56..f6e907aa 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -341,6 +341,9 @@ class DozerFactors: 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: @@ -379,3 +382,296 @@ def dozer_hourly_output(factors: DozerFactors) -> Decimal: def dozer_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: """그 규격의 전진·후진 속도(2단). 표에 없는 규격이면 `None` — 지어내지 않는다.""" return _DOZER_SPEEDS_2ND_GEAR.get(tonnage) + + +#: 삽날 용량 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"), +} + +#: 타이어형 2단 속도 — 8-2-1 2.나. +_DOZER_TIRE_SPEEDS_2ND_GEAR = { + Decimal("15"): (Decimal(200), Decimal(125)), + Decimal("28"): (Decimal(200), Decimal(200)), + Decimal("33"): (Decimal(210), Decimal(250)), +} + +#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. +_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 dozer_tire_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: + """타이어형 전진·후진 속도(2단). 표에 없으면 `None`.""" + return _DOZER_TIRE_SPEEDS_2ND_GEAR.get(tonnage) + + +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 톤 + + ⚠ **2단만 되짚는다.** 1·3·4단 속도표는 여기 안 들고 있어, 다른 단이면 `None` 이다. + """ + if gear != 2: + return None + candidates = [] + for (track, tonnage), blade in _DOZER_BLADE_M3.items(): + if blade != blade_m3: + continue + speeds = dozer_speeds(tonnage) if track == "무한궤도" else dozer_tire_speeds(tonnage) + if speeds == (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 label and 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` 갈래에 맞는 작업효율을 못 골랐습니다.", + ) + + +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`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에 + 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 + 때만** 굴착기 쪽으로 간다. + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + 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 + book.add_detail( + PriceDetail( + title_code, + hourly_code, + dozer_machine_hours_per_unit(factors), + note=factors.formula_text, + ) + ) + return Decimal(100) + return _ZERO diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 043e61c4..985ce1e9 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -27,7 +27,11 @@ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, FactorGap, + attach_dozer_share, + dozer_variants, + formula_machine_codes, extract_cycle_factors, + extract_dozer_factors, machine_hours_per_unit, ) from B09_Estimation.B09_Estimation_MachineOperating import ( @@ -325,6 +329,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: _add_labor_titles(build.book, wages) machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} + # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. + machine_codes |= formula_machine_codes(master) build.incomplete_machines = _add_machine_layers(build.book, machine_codes) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 @@ -341,6 +347,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: code = node.get("work_item_code") if not code or (code, "") in by_item: continue + # 불도저 운반(8-2-1)은 **갈래(토사·파쇄암·발파암)마다 값이 다르다** — 한 벌로 + # 뭉치면 어느 것도 안 맞는다. 갈래가 있으면 갈래를 세우고 여기서 끝낸다. + labels = dozer_variants(node) + if labels: + for label in labels: + by_item.setdefault((code, label), []) + continue for table in node.get("tables", []): if isinstance(extract_cycle_factors(code, table), CycleFactors): by_item[(code, "")] = [] @@ -410,9 +423,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: ) # 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4). - machine_share = ( - _ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code) + # ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번 + # 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다. + machine_share = attach_dozer_share( + build.book, build.factor_gaps, master, work_item_code, title_code, variant ) + if not machine_share and not variant: + machine_share = _attach_machine_share(build, master, work_item_code, title_code) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 @@ -422,6 +439,15 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: if work_item_code in axis.partial_items: build.partial_ratio.setdefault(work_item_code, _ZERO) + # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 + # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. + if not attachable and not build.book.details.get(title_code): + build.book.titles.pop(title_code, None) + if variant in build.variants.get(work_item_code, []): + build.variants[work_item_code].remove(variant) + build.skipped.append(work_item_code) + continue + covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build) if covered is not None: covered += machine_share @@ -440,6 +466,7 @@ def _has_full_formula(master: dict, work_item_code: str) -> bool: return False return any( isinstance(extract_cycle_factors(work_item_code, table), CycleFactors) + or isinstance(extract_dozer_factors(work_item_code, table), dict) for table in node.get("tables", []) ) From b3c6f296842db0b4e8d8ff218d76b47476005ce5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 11:54:12 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor(B09):=20=EB=B6=88=EB=8F=84?= =?UTF-8?q?=EC=A0=80=EB=A5=BC=20=EB=94=B4=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,=20=EC=9E=A5=EB=B9=84=20=EB=B6=99=EC=9D=B4?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20=ED=95=9C=20=EC=BC=A4?= =?UTF-8?q?=EB=A0=88=EB=A1=9C=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B09_Estimation_MachineProductivity_Dozer.py 신설 (8-2-1 전용). 굴착기(8-1-4)와 한 파일에 두면 700줄을 넘고, 두 식이 섞여 읽힘. - 굴착기 쪽 장비 붙이기를 attach_machine_share 로 올려 불도저 쪽 attach_dozer_share 와 같은 모양으로 맞춤 — 자리를 나눠 쓰는 것이 눈에 보이게. - 줄수: UnitPrice 652 · MachineProductivity 360 · Dozer 398. - 224건 통과 (동작 변화 없음). Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_MachineProductivity.py | 367 ++-------------- ...09_Estimation_MachineProductivity_Dozer.py | 398 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 69 +-- 3 files changed, 436 insertions(+), 398 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index f6e907aa..5a328f03 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -301,377 +301,60 @@ def _ratio_of(cell: str) -> Decimal | None: 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 - #: 표가 기종 이름을 안 적어 `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}) × {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) - - -#: 삽날 용량 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"), -} - -#: 타이어형 2단 속도 — 8-2-1 2.나. -_DOZER_TIRE_SPEEDS_2ND_GEAR = { - Decimal("15"): (Decimal(200), Decimal(125)), - Decimal("28"): (Decimal(200), Decimal(200)), - Decimal("33"): (Decimal(210), Decimal(250)), -} - -#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. -_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 dozer_tire_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: - """타이어형 전진·후진 속도(2단). 표에 없으면 `None`.""" - return _DOZER_TIRE_SPEEDS_2ND_GEAR.get(tonnage) - - -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 톤 - - ⚠ **2단만 되짚는다.** 1·3·4단 속도표는 여기 안 들고 있어, 다른 단이면 `None` 이다. - """ - if gear != 2: - return None - candidates = [] - for (track, tonnage), blade in _DOZER_BLADE_M3.items(): - if blade != blade_m3: - continue - speeds = dozer_speeds(tonnage) if track == "무한궤도" else dozer_tire_speeds(tonnage) - if speeds == (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 label and 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` 갈래에 맞는 작업효율을 못 골랐습니다.", - ) - - -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( +def attach_machine_share( book: Any, factor_gaps: dict[str, FactorGap], + cycle_factors: dict[str, CycleFactors], master: dict[str, Any], work_item_code: str, title_code: str, - variant: str, ) -> Decimal: - """불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0. + """시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다. - ⚠ 굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에 - 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 - 때만** 굴착기 쪽으로 간다. + ⚠ 불도저 쪽(`attach_dozer_share`)과 **자리를 나눠 쓴다** — 두 식이 한 일위대가에 + 붙으면 장비를 두 번 세는 것이 된다. + + 계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은 + `partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다). """ - from B09_Estimation.B09_Estimation_PriceBook import PriceDetail - 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) + + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + 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: + factors = extract_cycle_factors(work_item_code, table) + if not isinstance(factors, CycleFactors): + if isinstance(factors, FactorGap): + factor_gaps[work_item_code] = factors 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, + pum_table_id=factors.pum_table_id, missing=("시간당 사용료",), note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", ) continue + share = ( + Decimal(1) + if factors.machine_ratio_pct is None + else Decimal(str(factors.machine_ratio_pct)) / Decimal(100) + ) book.add_detail( PriceDetail( title_code, hourly_code, - dozer_machine_hours_per_unit(factors), + machine_hours_per_unit(factors) * share, note=factors.formula_text, ) ) - return Decimal(100) + cycle_factors[work_item_code] = factors + return share * Decimal(100) return _ZERO diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py new file mode 100644 index 00000000..c0f49e5e --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -0,0 +1,398 @@ +"""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, + parse_measure, +) + +_ZERO = Decimal(0) + + +_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 + #: 표가 기종 이름을 안 적어 `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}) × {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) + + +#: 삽날 용량 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"), +} + +#: 타이어형 2단 속도 — 8-2-1 2.나. +_DOZER_TIRE_SPEEDS_2ND_GEAR = { + Decimal("15"): (Decimal(200), Decimal(125)), + Decimal("28"): (Decimal(200), Decimal(200)), + Decimal("33"): (Decimal(210), Decimal(250)), +} + +#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. +_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 dozer_tire_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: + """타이어형 전진·후진 속도(2단). 표에 없으면 `None`.""" + return _DOZER_TIRE_SPEEDS_2ND_GEAR.get(tonnage) + + +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 톤 + + ⚠ **2단만 되짚는다.** 1·3·4단 속도표는 여기 안 들고 있어, 다른 단이면 `None` 이다. + """ + if gear != 2: + return None + candidates = [] + for (track, tonnage), blade in _DOZER_BLADE_M3.items(): + if blade != blade_m3: + continue + speeds = dozer_speeds(tonnage) if track == "무한궤도" else dozer_tire_speeds(tonnage) + if speeds == (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 label and 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` 갈래에 맞는 작업효율을 못 골랐습니다.", + ) + + +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`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에 + 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 + 때만** 굴착기 쪽으로 간다. + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + 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 + book.add_detail( + PriceDetail( + title_code, + hourly_code, + dozer_machine_hours_per_unit(factors), + note=factors.formula_text, + ) + ) + return Decimal(100) + return _ZERO diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 985ce1e9..5e05318f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -27,12 +27,14 @@ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, FactorGap, + attach_machine_share, + extract_cycle_factors, +) +from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import ( attach_dozer_share, dozer_variants, - formula_machine_codes, - extract_cycle_factors, extract_dozer_factors, - machine_hours_per_unit, + formula_machine_codes, ) from B09_Estimation.B09_Estimation_MachineOperating import ( load_fuel_price, @@ -429,7 +431,14 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: build.book, build.factor_gaps, master, work_item_code, title_code, variant ) if not machine_share and not variant: - machine_share = _attach_machine_share(build, master, work_item_code, title_code) + machine_share = attach_machine_share( + build.book, + build.factor_gaps, + build.cycle_factors, + master, + work_item_code, + title_code, + ) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 @@ -477,58 +486,6 @@ def _share_of(row) -> Decimal: return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100) -def _attach_machine_share( - build: UnitPriceBuild, - master: dict, - work_item_code: str, - title_code: str, -) -> Decimal: - """시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다. - - 계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은 - `partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다). - """ - 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 - - for table in node.get("tables", []): - factors = extract_cycle_factors(work_item_code, table) - if not isinstance(factors, CycleFactors): - if isinstance(factors, FactorGap): - build.factor_gaps[work_item_code] = factors - continue - hourly_code = f"X-{factors.machine_code}" - if hourly_code not in build.book.titles: - # 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다. - build.factor_gaps[work_item_code] = FactorGap( - work_item_code=work_item_code, - pum_table_id=factors.pum_table_id, - missing=("시간당 사용료",), - note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", - ) - continue - share = ( - Decimal(1) - if factors.machine_ratio_pct is None - else Decimal(str(factors.machine_ratio_pct)) / Decimal(100) - ) - build.book.add_detail( - PriceDetail( - title_code, - hourly_code, - machine_hours_per_unit(factors) * share, - note=factors.formula_text, - ) - ) - build.cycle_factors[work_item_code] = factors - return share * Decimal(100) - return _ZERO - - def _covered_ratio_pct( rows: list, attached_refs: set[str], build: UnitPriceBuild ) -> Decimal | None: From b31062346859c7269c740850f6ce2d3892245786 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 11:57:32 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B09):=20=EB=B6=88=EB=8F=84=EC=A0=80=20?= =?UTF-8?q?=EB=8B=A8(gear)=EB=B3=84=20=EC=86=8D=EB=8F=84=ED=91=9C=C2=B7?= =?UTF-8?q?=EB=B3=B4=EC=A0=95=EC=8B=9D=20=EC=9D=BD=EC=96=B4=20=EB=B9=84?= =?UTF-8?q?=EB=8B=A4=EC=A7=90(=EC=A0=95=EC=A7=80)=20=EC=84=B8=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 8-2-1 속도표를 단(1·2·3단)까지 전부 담음. 무한궤도 13톤·타이어의 없는 후진 3단은 담지 않음 — 없는 속도로 싸이클이 서면 값이 조용히 틀림. - 「(0.55-0.1)」 같은 보정식을 읽음 (8-2-1 [주]④ 정지작업 −0.1). 범위 표기와 갈라 다룸 — 범위는 여전히 확정값 아님. - 갈래 없는 표(「f | 1/1.3」)도 읽음. - 결과: FP-09-17-02 비다짐(정지) 1,410.1 원/㎥ 섬 (3단 75/98, E=0.45, 19톤). - 시험 4건 추가, 228건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- ...09_Estimation_MachineProductivity_Dozer.py | 99 +++++++++++++------ 1 file changed, 70 insertions(+), 29 deletions(-) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py index c0f49e5e..8ff27a9b 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -36,14 +36,47 @@ from B09_Estimation.B09_Estimation_MachineProductivity import ( _ZERO = Decimal(0) -_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)), +#: 전진·후진 속도 (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") @@ -100,9 +133,11 @@ def dozer_hourly_output(factors: DozerFactors) -> Decimal: return output -def dozer_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: - """그 규격의 전진·후진 속도(2단). 표에 없는 규격이면 `None` — 지어내지 않는다.""" - return _DOZER_SPEEDS_2ND_GEAR.get(tonnage) +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.가. **규격을 되짚는 열쇠**로도 쓴다. @@ -120,13 +155,6 @@ _DOZER_BLADE_M3 = { ("타이어", Decimal("33")): Decimal("5.7"), } -#: 타이어형 2단 속도 — 8-2-1 2.나. -_DOZER_TIRE_SPEEDS_2ND_GEAR = { - Decimal("15"): (Decimal(200), Decimal(125)), - Decimal("28"): (Decimal(200), Decimal(200)), - Decimal("33"): (Decimal(210), Decimal(250)), -} - #: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. _DOZER_WET_TONS = (Decimal("4"), Decimal("13")) @@ -149,11 +177,6 @@ _DOZER_GROUP_KEYS = ("E", "f") _RE_GEAR = re.compile(r"(\d+)\s*단") -def dozer_tire_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: - """타이어형 전진·후진 속도(2단). 표에 없으면 `None`.""" - return _DOZER_TIRE_SPEEDS_2ND_GEAR.get(tonnage) - - def resolve_dozer( blade_m3: Decimal, forward: Decimal, @@ -167,16 +190,13 @@ def resolve_dozer( q゚ 3.2㎥ + 55/70 m/분(2단) → 불도저(무한궤도) 19 톤 - ⚠ **2단만 되짚는다.** 1·3·4단 속도표는 여기 안 들고 있어, 다른 단이면 `None` 이다. + ⚠ **단(gear)까지 맞아야 한다** — 같은 삽날이라도 단이 다르면 다른 규격을 가리킨다. """ - if gear != 2: - return None candidates = [] for (track, tonnage), blade in _DOZER_BLADE_M3.items(): if blade != blade_m3: continue - speeds = dozer_speeds(tonnage) if track == "무한궤도" else dozer_tire_speeds(tonnage) - if speeds == (forward, reverse): + if dozer_speeds(tonnage, gear, track) == (forward, reverse): candidates.append((track, tonnage)) if len(candidates) != 1: return None @@ -235,7 +255,11 @@ def extract_dozer_factors( current = head label = cells[1] if len(cells) > 1 else "" value = _first_measure(cells[2:]) - if label and value is not None: + 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」. 값이 없으면 갈래 줄이 아니다. @@ -295,6 +319,23 @@ def extract_dozer_factors( ) +_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.55∼0.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())