feat(z01·b09): 코드에 박혀 있던 기계 작업량 밑값을 마스터로 꺼냄 — 새 자료 data_machine_productivity(불도저 속도 25 · 삽날 10 · 덤프 운반·적재 밑값 9 · 재료별 3) · B09 는 그 파일을 읽음(값은 한 칸도 안 바꿈 · 원문 pum_const C0426·C0427 대조 시험) · Z01 kind machine_productivity 로 관리자가 고칠 수 있게 · 식은 값이 아니라 로직이라 코드에 남김 · 이름표에 없는 새 자료는 폴더에서 찾음(사용자 원칙 「마스터 = 관리자가 제어할 값 · 코드 안에 있으면 안 됨」 · 브레인)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-16 15:19:08 +09:00
co-authored by Claude Opus 5
parent 75a84552f1
commit 028a6be247
7 changed files with 644 additions and 80 deletions
@@ -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")}
@@ -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"))
@@ -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:
+7 -3
View File
@@ -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)
@@ -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,
@@ -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」)"
}
]
}
}
}
+67
View File
@@ -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)