From 028a6be2470f17e3183718f929d8acf8da773cac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 15:19:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(z01=C2=B7b09):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EB=B0=95=ED=98=80=20=EC=9E=88=EB=8D=98=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=20=EC=9E=91=EC=97=85=EB=9F=89=20=EB=B0=91=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=A7=88=EC=8A=A4=ED=84=B0=EB=A1=9C=20=EA=BA=BC?= =?UTF-8?q?=EB=83=84=20=E2=80=94=20=EC=83=88=20=EC=9E=90=EB=A3=8C=20data?= =?UTF-8?q?=5Fmachine=5Fproductivity(=EB=B6=88=EB=8F=84=EC=A0=80=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=2025=20=C2=B7=20=EC=82=BD=EB=82=A0=2010=20?= =?UTF-8?q?=C2=B7=20=EB=8D=A4=ED=94=84=20=EC=9A=B4=EB=B0=98=C2=B7=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EB=B0=91=EA=B0=92=209=20=C2=B7=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=EB=B3=84=203)=20=C2=B7=20B09=20=EB=8A=94=20=EA=B7=B8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=9D=BD=EC=9D=8C(=EA=B0=92?= =?UTF-8?q?=EC=9D=80=20=ED=95=9C=20=EC=B9=B8=EB=8F=84=20=EC=95=88=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=C2=B7=20=EC=9B=90=EB=AC=B8=20pum=5Fconst?= =?UTF-8?q?=20C0426=C2=B7C0427=20=EB=8C=80=EC=A1=B0=20=EC=8B=9C=ED=97=98)?= =?UTF-8?q?=20=C2=B7=20Z01=20kind=20machine=5Fproductivity=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=EA=B0=80=20=EA=B3=A0=EC=B9=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C=20=C2=B7=20=EC=8B=9D=EC=9D=80=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=B4=EB=9D=BC=20=EC=BD=94=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EA=B9=80=20=C2=B7=20=EC=9D=B4=EB=A6=84=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=83=88=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=EB=8A=94=20=ED=8F=B4=EB=8D=94=EC=97=90=EC=84=9C=20=EC=B0=BE?= =?UTF-8?q?=EC=9D=8C(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9B=90=EC=B9=99=20?= =?UTF-8?q?=E3=80=8C=EB=A7=88=EC=8A=A4=ED=84=B0=20=3D=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EC=A0=9C=EC=96=B4=ED=95=A0=20=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=EC=BD=94=EB=93=9C=20=EC=95=88=EC=97=90=20=EC=9E=88?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EC=95=88=20=EB=90=A8=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- ...B09_Estimation_MachineProductivity_Data.py | 68 +++ ...09_Estimation_MachineProductivity_Dozer.py | 52 +-- ...B09_Estimation_MachineProductivity_Dump.py | 58 +-- Z01_MasterData/Z01_MasterData_BasePrices.py | 10 +- .../Z01_MasterData_BasePrices_Tables.py | 83 ++++ .../machine_productivity_2026-01-01.json | 386 ++++++++++++++++++ resources/tester/test_z01_base_tables.py | 67 +++ 7 files changed, 644 insertions(+), 80 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineProductivity_Data.py create mode 100644 resources/data_machine_productivity/machine_productivity_2026-01-01.json diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Data.py b/B09_Estimation/B09_Estimation_MachineProductivity_Data.py new file mode 100644 index 00000000..828f1cb9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Data.py @@ -0,0 +1,68 @@ +"""B09 — 기계 작업량 **밑값**을 마스터에서 읽는다(2026-09-16 사용자 원칙 · 브레인 지시). + + 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨.** + +그동안 불도저 속도·삽날 용량(건설품셈 8-2-1)과 덤프 운반·적재 식 계수(산림품셈 10-12)가 +코드 상수로 박혀 있었다. 값은 그대로 두고 자리만 옮긴다 — +`resources/data_machine_productivity/machine_productivity_<판>.json`. +⚠ **식은 여기 없다.** `Q = n·q·f·E` 같은 식은 값이 아니라 로직이라 계산 모듈에 남는다. +⚠ 옮기면서 값을 하나도 안 바꿨다 — 원문(pum_const C0426·C0427)과의 대조는 시험이 지킨다. +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from functools import lru_cache +from pathlib import Path +from typing import Any + +DATA_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_machine_productivity" +PREFIX = "machine_productivity_" + + +class MachineProductivityDataError(FileNotFoundError): + """밑값 판이 없다 — 지어내지 않고 멈춘다.""" + + +@lru_cache(maxsize=2) +def _read(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def load() -> dict[str, Any]: + """끝 판 한 벌(이름 차례).""" + found = sorted(DATA_DIR.glob(PREFIX + "*.json")) if DATA_DIR.is_dir() else [] + if not found: + raise MachineProductivityDataError(f"기계 작업량 밑값 판이 없습니다: {DATA_DIR}") + return _read(str(found[-1]), found[-1].stat().st_mtime_ns) + + +def records(variable: str) -> list[dict[str, Any]]: + return load()["variables"][variable]["records"] + + +def dozer_speeds() -> dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]]: + """{무한궤도·타이어: {규격(ton): {단: (전진, 후진)}}} — 후진이 원문에 없는 단은 담기지 않는다.""" + out: dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]] = {} + for row in records("dozer_speed"): + track = out.setdefault(row["track"], {}) + gears = track.setdefault(Decimal(row["tonnage_ton"]), {}) + gears[int(row["gear"])] = ( + Decimal(row["forward_m_per_min"]), + Decimal(row["reverse_m_per_min"]), + ) + return out + + +def dozer_blades() -> dict[tuple[str, Decimal], Decimal]: + """{(무한궤도·타이어, 규격(ton)): 삽날 용량 q˚(㎥)}.""" + return { + (row["track"], Decimal(row["tonnage_ton"])): Decimal(row["blade_m3"]) + for row in records("dozer_blade") + } + + +def haul_params() -> dict[str, Decimal]: + """덤프 운반·적재 식이 쓰는 밑값(적재 규격 T · 버켓 q · 싸이클 · 주행속도 · 적하·대기·덮개).""" + return {row["key"]: Decimal(row["value"]) for row in records("dump_haul")} diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py index 6aa9dbd1..e27464e1 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -23,6 +23,7 @@ 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_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_MachineProductivity import ( CycleFactors, @@ -38,47 +39,13 @@ _ZERO = Decimal(0) #: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.** +#: ⭐ 값은 마스터 `resources/data_machine_productivity/` 에 있다 — 여기 박지 않는다(사용자 원칙). #: {규격(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))}, - }, -} +_DOZER_SPEEDS = data.dozer_speeds() # 마스터에서 읽음 — 값은 그대로(2026-09-16 꺼냄) #: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」 _DOZER_GEAR_SHIFT_MIN = Decimal("0.25") _MINUTES_PER_HOUR = Decimal(60) @@ -144,18 +111,7 @@ def dozer_speeds( #: 삽날 용량 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_BLADE_M3 = data.dozer_blades() # 마스터에서 읽음 #: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. _DOZER_WET_TONS = (Decimal("4"), Decimal("13")) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py index 361ed6bc..b41764b0 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dump.py @@ -31,19 +31,23 @@ 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" -_TRUCK_TON = Decimal(15) -_BUCKET_M3 = Decimal("0.7") -_LOADER_CYCLE_SEC = Decimal(20) -_V_LOADED_KMH = Decimal(5) -_V_EMPTY_KMH = Decimal(6) -_T3_UNLOAD, _T4_WAIT, _T5_COVER = Decimal("1.1"), Decimal("0.9"), Decimal("0.5") -_SIXTY = Decimal(60) +#: ⭐ 식이 쓰는 밑값은 **마스터**에 있다 — `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) @@ -66,37 +70,33 @@ _COMMON_NOTE = ( _ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" DUMP_MATERIALS: dict[str, DumpMaterial] = { - "FP-10-12-01": DumpMaterial( - "FP-10-12-01", "토사", Decimal("1.9"), Decimal("1.3"), Decimal("0.9"), Decimal("0.85"), - Decimal("0.9"), (_COMMON_NOTE, _ES_NOTE), - ), - "FP-10-12-02": DumpMaterial( - "FP-10-12-02", "암절취", Decimal("2.4"), Decimal("1.35"), Decimal("0.55"), Decimal("0.35"), - Decimal("0.9"), (_COMMON_NOTE + " · 원문 10 이면 n 1.18배", _ES_NOTE), - ), - "FP-10-12-03": DumpMaterial( - "FP-10-12-03", "발파암", Decimal("2.4"), Decimal("1.625"), Decimal("0.55"), Decimal("0.35"), - Decimal("0.9"), - (_COMMON_NOTE, _ES_NOTE, "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"), - ), -} # fmt: skip + 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 = Decimal(22) +_LOADING_CYCLE_SEC = _HAUL["loading_cycle_sec"] _LOADING_E0 = { - "FP-10-12-01": Decimal("0.60"), - "FP-10-12-02": Decimal("0.35"), - "FP-10-12-03": Decimal("0.35"), + row["work_item_code"]: Decimal(row["loading_efficiency"]) + for row in data.records("dump_material") } +#: 적재 E0 사유 — 마스터가 줄에 따로 들고 있음(운반 줄 사유와 섞지 않음). _LOADING_NOTES = { - "FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림", - "FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)", - "FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)", -} # fmt: skip + row["work_item_code"]: row.get("loading_note", "") for row in data.records("dump_material") +} def loading_output(code: str) -> Decimal: diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index fcd75a28..7e4e4e91 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -45,12 +45,16 @@ _RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름 def _source(file_id: str) -> Path: + """원본 파일 자리 — 이름표가 가리키는 곳이 먼저, 아직 이름표에 없는 새 자료는 폴더에서 찾음.""" labels = tables.load_labels() entry = next((f for f in labels["files"] if f["file_id"] == file_id), None) path = tables._file_path(entry) if entry else None - if path is None: - raise FileNotFoundError(f"이름표에 원본 자리가 없음: {file_id}") - return path + if path is not None: + return path + found = sorted(tables.RESOURCES.glob(f"data_*/{file_id}*.json")) + if not found: + raise FileNotFoundError(f"원본 자리를 못 찾음: {file_id}") + return found[-1] @lru_cache(maxsize=16) diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py index da07b770..dca7b6e3 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -281,6 +281,73 @@ def _masonry_class(doc: dict[str, Any], name: str) -> list[_ROW]: return rows +def _machine_productivity(doc: dict[str, Any], name: str) -> list[_ROW]: + """기계 작업량 밑값 — 코드에 박혀 있던 값을 꺼낸 자리(불도저 속도·삽날 · 덤프 운반·적재 계수). + + ⚠ 식(`Q = n·q·f·E` 따위)은 값이 아니라 로직이라 계산 모듈에 남는다 — 여기는 계수만. + """ + rows = [] + for r in doc["variables"]["dozer_speed"]["records"]: + rows.append( + _row( + f"dozer_speed/{r['track']}/{r['tonnage_ton']}/{r['gear']}", + name, + ("table", "track", "tonnage_ton", "gear"), + table="dozer_speed", + track=r["track"], + tonnage_ton=float(r["tonnage_ton"]), + gear=int(r["gear"]), + forward_m_per_min=float(r["forward_m_per_min"]), + reverse_m_per_min=float(r["reverse_m_per_min"]), + ) + ) + for r in doc["variables"]["dozer_blade"]["records"]: + rows.append( + _row( + f"dozer_blade/{r['track']}/{r['tonnage_ton']}", + name, + ("table", "track", "tonnage_ton"), + table="dozer_blade", + track=r["track"], + tonnage_ton=float(r["tonnage_ton"]), + blade_m3=float(r["blade_m3"]), + ) + ) + for r in doc["variables"]["dump_haul"]["records"]: + rows.append( + _row( + f"dump_haul/{r['key']}", + name, + ("table", "key"), + table="dump_haul", + key=r["key"], + value=float(r["value"]), + unit=r.get("unit"), + meaning=r.get("meaning"), + ) + ) + for r in doc["variables"]["dump_material"]["records"]: + rows.append( + _row( + f"dump_material/{r['work_item_code']}", + name, + ("table", "work_item_code"), + table="dump_material", + work_item_code=r["work_item_code"], + label=r["label"], + unit_weight_ton_per_m3=float(r["unit_weight_ton_per_m3"]), + loose_factor=float(r["loose_factor"]), + bucket_factor=float(r["bucket_factor"]), + loader_efficiency=float(r["loader_efficiency"]), + truck_efficiency=float(r["truck_efficiency"]), + loading_efficiency=float(r["loading_efficiency"]), + notes=r.get("notes"), + loading_note=r.get("loading_note"), + ) + ) + return rows + + #: kind → 원본 파일 id · 줄 만드는 길 · 고칠 칸. 이름은 **자료 이름 그대로**(브레인). SPEC: dict[str, dict[str, Any]] = { "coef": {"file": "coef", "build": _coef, "editable": ["min", "max"]}, @@ -315,6 +382,22 @@ SPEC: dict[str, dict[str, Any]] = { "editable": ["min_cm", "max_cm"], }, "stone_kind": {"file": "stone_kind", "build": _stone_kind, "editable": ["m3_per_m2", "ratio"]}, + "machine_productivity": { + "file": "machine_productivity", + "build": _machine_productivity, + "editable": [ + "forward_m_per_min", + "reverse_m_per_min", + "blade_m3", + "value", + "unit_weight_ton_per_m3", + "loose_factor", + "bucket_factor", + "loader_efficiency", + "truck_efficiency", + "loading_efficiency", + ], + }, "masonry_class": { "file": "masonry_class", "build": _masonry_class, diff --git a/resources/data_machine_productivity/machine_productivity_2026-01-01.json b/resources/data_machine_productivity/machine_productivity_2026-01-01.json new file mode 100644 index 00000000..ff05aac3 --- /dev/null +++ b/resources/data_machine_productivity/machine_productivity_2026-01-01.json @@ -0,0 +1,386 @@ +{ + "schema_version": "1.0", + "dataset_id": "machine_productivity", + "effective_date": "2026-01-01", + "note": "기계 작업량 밑값 — 코드에 박혀 있던 값을 그대로 옮긴 벌(2026-09-16 사용자 원칙 「마스터 = 관리자가 제어할 값 · 코드 안에 있으면 안 됨」). 값은 한 칸도 안 바꿨다 — 옮기기 전후 금액이 같다.", + "policy": { + "no_value_changed": "코드에 있던 값을 그대로 옮겼다. 원문과의 대조는 시험이 지킨다.", + "formula_stays_in_code": "식(Q = n·q·f·E 따위)은 값이 아니라 로직이라 코드에 둔다 — 계수만 여기 있다.", + "source_tables_mashed": "삽날 용량표는 원문 옮기기에서 절 이름이 뭉개져(`5-3.2-5.5-타 이 어…`) 표로 안 들어왔다. 값은 원문 본문 것이고, 원문 표가 제 모양으로 들어오면 그때 대조한다." + }, + "sources": [ + { + "doc": "건설공사 표준품셈 8-2-1 불도저 2.가·나 전진·후진 속도", + "tables": [ + "C0426", + "C0427" + ], + "role": "primary" + }, + { + "doc": "건설공사 표준품셈 8-2-1 1.가 삽날 용량 q˚", + "tables": [], + "role": "primary", + "note": "원문 표가 뭉개져 카탈로그에 표로 없음" + }, + { + "doc": "산림사업 표준품셈(고시 제2025-82호) 10-12 「1. 적재」·「2. 운반」 식 서식", + "tables": [], + "role": "primary", + "note": "원문이 표가 아니라 채워 넣는 식이라 공종 마스터에 안 실림" + } + ], + "variables": { + "dozer_speed": { + "unit": "m/분", + "key": "track+tonnage_ton+gear", + "note": "후진이 「-」인 단은 원문에 값이 없어 담지 않는다(없는 속도로 싸이클이 서면 값이 조용히 틀림).", + "records": [ + { + "track": "무한궤도", + "tonnage_ton": "4", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "63" + }, + { + "track": "무한궤도", + "tonnage_ton": "4", + "gear": 2, + "forward_m_per_min": "57", + "reverse_m_per_min": "85" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "gear": 1, + "forward_m_per_min": "43", + "reverse_m_per_min": "53" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "gear": 2, + "forward_m_per_min": "67", + "reverse_m_per_min": "78" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "gear": 3, + "forward_m_per_min": "92", + "reverse_m_per_min": "107" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "gear": 1, + "forward_m_per_min": "42", + "reverse_m_per_min": "50" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "gear": 2, + "forward_m_per_min": "64", + "reverse_m_per_min": "75" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "gear": 3, + "forward_m_per_min": "88", + "reverse_m_per_min": "105" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "48" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "gear": 2, + "forward_m_per_min": "55", + "reverse_m_per_min": "70" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "gear": 3, + "forward_m_per_min": "75", + "reverse_m_per_min": "100" + }, + { + "track": "무한궤도", + "tonnage_ton": "13", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "48" + }, + { + "track": "무한궤도", + "tonnage_ton": "13", + "gear": 2, + "forward_m_per_min": "55", + "reverse_m_per_min": "70" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "46" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "gear": 2, + "forward_m_per_min": "55", + "reverse_m_per_min": "70" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "gear": 3, + "forward_m_per_min": "75", + "reverse_m_per_min": "98" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "gear": 1, + "forward_m_per_min": "40", + "reverse_m_per_min": "43" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "gear": 2, + "forward_m_per_min": "52", + "reverse_m_per_min": "58" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "gear": 3, + "forward_m_per_min": "70", + "reverse_m_per_min": "78" + }, + { + "track": "타이어", + "tonnage_ton": "15", + "gear": 1, + "forward_m_per_min": "83", + "reverse_m_per_min": "92" + }, + { + "track": "타이어", + "tonnage_ton": "15", + "gear": 2, + "forward_m_per_min": "200", + "reverse_m_per_min": "125" + }, + { + "track": "타이어", + "tonnage_ton": "28", + "gear": 1, + "forward_m_per_min": "92", + "reverse_m_per_min": "92" + }, + { + "track": "타이어", + "tonnage_ton": "28", + "gear": 2, + "forward_m_per_min": "200", + "reverse_m_per_min": "200" + }, + { + "track": "타이어", + "tonnage_ton": "33", + "gear": 1, + "forward_m_per_min": "92", + "reverse_m_per_min": "110" + }, + { + "track": "타이어", + "tonnage_ton": "33", + "gear": 2, + "forward_m_per_min": "210", + "reverse_m_per_min": "250" + } + ] + }, + "dozer_blade": { + "unit": "㎥", + "key": "track+tonnage_ton", + "note": "무한궤도 10·13 톤이 둘 다 1.5 ㎥ 라 용량만으로는 못 가른다 — 속도로 마저 가른다.", + "records": [ + { + "track": "무한궤도", + "tonnage_ton": "4", + "blade_m3": "0.5" + }, + { + "track": "무한궤도", + "tonnage_ton": "7", + "blade_m3": "1.1" + }, + { + "track": "무한궤도", + "tonnage_ton": "10", + "blade_m3": "1.5" + }, + { + "track": "무한궤도", + "tonnage_ton": "12", + "blade_m3": "2.0" + }, + { + "track": "무한궤도", + "tonnage_ton": "13", + "blade_m3": "1.5" + }, + { + "track": "무한궤도", + "tonnage_ton": "19", + "blade_m3": "3.2" + }, + { + "track": "무한궤도", + "tonnage_ton": "32", + "blade_m3": "5.5" + }, + { + "track": "타이어", + "tonnage_ton": "15", + "blade_m3": "3.1" + }, + { + "track": "타이어", + "tonnage_ton": "28", + "blade_m3": "4.0" + }, + { + "track": "타이어", + "tonnage_ton": "33", + "blade_m3": "5.7" + } + ] + }, + "dump_haul": { + "key": "key", + "note": "덤프 운반·적재 식이 쓰는 밑값. 장비 규격이 바뀌면 여기를 고친다.", + "records": [ + { + "key": "truck_ton", + "value": "15", + "unit": "ton", + "meaning": "덤프트럭 적재 규격 T" + }, + { + "key": "bucket_m3", + "value": "0.7", + "unit": "㎥", + "meaning": "적재기계 버켓 용량 q" + }, + { + "key": "loader_cycle_sec", + "value": "20", + "unit": "초", + "meaning": "적재 대기 싸이클 ㎝s" + }, + { + "key": "speed_loaded_kmh", + "value": "5", + "unit": "㎞/hr", + "meaning": "적재 주행속도 V1" + }, + { + "key": "speed_empty_kmh", + "value": "6", + "unit": "㎞/hr", + "meaning": "공차 주행속도 V2" + }, + { + "key": "unload_min", + "value": "1.1", + "unit": "분", + "meaning": "적하 시간 t3" + }, + { + "key": "wait_min", + "value": "0.9", + "unit": "분", + "meaning": "대기 시간 t4" + }, + { + "key": "cover_min", + "value": "0.5", + "unit": "분", + "meaning": "덮개 시간 t5" + }, + { + "key": "loading_cycle_sec", + "value": "22", + "unit": "초", + "meaning": "적재 식 싸이클 ㎝(180°)" + } + ] + }, + "dump_material": { + "key": "work_item_code", + "note": "10-12 절 재료별 값 — 고른 값·버린 값 사유를 줄에 함께 둔다. 적재 사유는 `loading_note` 로 따로 둔다(운반 줄 사유와 섞지 않음).", + "records": [ + { + "work_item_code": "FP-10-12-01", + "label": "토사", + "unit_weight_ton_per_m3": "1.9", + "loose_factor": "1.3", + "bucket_factor": "0.9", + "loader_efficiency": "0.85", + "truck_efficiency": "0.9", + "loading_efficiency": "0.60", + "notes": [ + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)", + "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" + ], + "loading_note": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림" + }, + { + "work_item_code": "FP-10-12-02", + "label": "암절취", + "unit_weight_ton_per_m3": "2.4", + "loose_factor": "1.35", + "bucket_factor": "0.55", + "loader_efficiency": "0.35", + "truck_efficiency": "0.9", + "loading_efficiency": "0.35", + "notes": [ + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림) · 원문 10 이면 n 1.18배", + "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)" + ], + "loading_note": "E0 = 0.35(본문·표 「파쇄암」 같음)" + }, + { + "work_item_code": "FP-10-12-03", + "label": "발파암", + "unit_weight_ton_per_m3": "2.4", + "loose_factor": "1.625", + "bucket_factor": "0.55", + "loader_efficiency": "0.35", + "truck_efficiency": "0.9", + "loading_efficiency": "0.35", + "notes": [ + "n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)", + "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)", + "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)" + ], + "loading_note": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)" + } + ] + } + } +} diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py index 97671f68..2ff80938 100644 --- a/resources/tester/test_z01_base_tables.py +++ b/resources/tester/test_z01_base_tables.py @@ -14,6 +14,8 @@ from __future__ import annotations import json from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] + import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -31,6 +33,7 @@ NEW_KINDS = ( "masonry_slope", "masonry_back_length", "stone_kind", + "machine_productivity", # 2026-09-16 코드에서 꺼낸 기계 작업량 밑값 "masonry_class", ) @@ -185,3 +188,67 @@ def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None: json={"values": {"reuse_count": 3}}, ) assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3 + + +def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None: + """사용자 원칙 — 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨**(2026-09-16). + + 불도저 속도·삽날(건설품셈 8-2-1)과 덤프 운반·적재 계수(산림품셈 10-12 식 서식)를 꺼낸 자리. + """ + table = _get(client, "machine_productivity", size=500) + rows = {r["@id"]: r for r in table["rows"]} + assert table["total"] == 25 + 10 + 9 + 3 + assert rows["dozer_speed/무한궤도/7/2"]["forward_m_per_min"] == 67 + assert rows["dozer_speed/타이어/33/2"]["reverse_m_per_min"] == 250 + assert rows["dozer_blade/무한궤도/19"]["blade_m3"] == 3.2 + assert rows["dump_haul/truck_ton"]["value"] == 15 + assert rows["dump_material/FP-10-12-01"]["loose_factor"] == 1.3 + assert "forward_m_per_min" in table["editable"] and "track" in table["locked"] + + +def test_꺼낸_값은_코드가_쓰던_것과_원문과_같음() -> None: + """옮기며 값이 바뀌면 금액이 조용히 달라짐 — 코드가 읽는 값과 품셈 원문 둘 다에 댐.""" + import json + import re + from decimal import Decimal + + from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import _DOZER_SPEEDS + from B09_Estimation.B09_Estimation_MachineProductivity_Dump import DUMP_MATERIALS + + assert _DOZER_SPEEDS["무한궤도"][Decimal("12")][2] == (Decimal(55), Decimal(70)) + assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9") + from B09_Estimation import B09_Estimation_MachineProductivity_Dump as dump + + assert (dump._TRUCK_TON, dump._BUCKET_M3, dump._LOADER_CYCLE_SEC) == ( + Decimal(15), + Decimal("0.7"), + Decimal(20), + ) + assert (dump._V_LOADED_KMH, dump._V_EMPTY_KMH, dump._LOADING_CYCLE_SEC) == ( + Decimal(5), + Decimal(6), + Decimal(22), + ) + pum = json.loads( + (ROOT / "resources/data_cost_input_value/pum_const_2026.json").read_text(encoding="utf-8") + )["variables"]["pum"]["tables"] + by_id = {t["table_id"]: t for t in pum} + + def source(table_id: str, gears: int) -> dict: + out = {} + for row in by_id[table_id]["rows"][1:]: + ton = re.sub(r"\(.*?\)", "", str(row[0])).strip() + if not ton.replace(".", "").isdigit(): + continue + cells = [str(v).strip() for v in row[1:]] + out[Decimal(ton)] = { + i + 1: (f, r) + for i, (f, r) in enumerate(zip(cells[:gears], cells[gears : gears * 2])) + } + return out + + for track, table_id, gears in (("무한궤도", "C0426", 4), ("타이어", "C0427", 3)): + original = source(table_id, gears) + for ton, speeds in _DOZER_SPEEDS[track].items(): + for gear, (forward, reverse) in speeds.items(): + assert (str(forward), str(reverse)) == original[ton][gear], (track, ton, gear)