일감 4·5 의 엔진 덩어리. PLAN 8-11·8-3·8-7. 프로젝트 설정 (common_util/common_util_project_settings.py) 자리 = <project_root>/project_settings.json, 루트의 project_manifest.json 옆. 매니페스트 stages 는 단계 산출물 목록이고 설정은 두 페이지가 함께 읽는 프로젝트 값이라 단계 폴더에 넣으면 주인이 애매해짐. 구획 quantity(B08) · estimation(B09) — 남의 구획은 읽기만. ⚠ 경계를 코드로 막음 — save_section() 이 이름 붙은 한 구획만 갈아 끼우고 통째로 쓰는 함수를 두지 않음. 두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라짐(오늘 main.py 에서 같은 모양의 사고를 이미 겪음). ⚠ *_override 기본은 None — 「안 정했으면 config 정본을 씀」. 기본값을 복사해 넣으면 정본이 바뀌어도 옛 프로젝트가 안 따라옴. ⚠ dataset_versions 는 기록이지 정본이 아님 — 되살릴 때 쓰는 정본은 프로젝트 스냅샷이고 어긋나면 스냅샷이 이김. estimation.rate_dataset 은 「연도」가 아니라 판을 가리킴 — 제비율은 연중에도 개정됨(현행판 2026-04-13). B09 확정 반영. 토공집계표 (열 = 구분·공종·규격·단위·계·비고, 거창 실무 시트 그대로) ⚠ 암 갈래 개수를 코드에 안 박음 — 울진 2 · 거창 5 · BOM 1 로 공사마다 다름. 프로젝트 설정의 세트를 받아 그만큼 줄을 냄. 비율이 아직 없으면 지어내지 않고 「암」 한 줄로 냄. 비율 합이 100 이 아니어도 준 비율끼리 안분해 총량 보존. ⚠ 반영률 기본 100 % — 실무 관측 80/50/80 은 참고이지 기본값 아님(법대로). 바꾼 경우에만 비고에 「80 % 반영」이 남음 — 누가 정한 값인지 보이게. 운반 가중평균 ((운반수단 x 지반유형)별 1줄) 실무 울진이 도자 토사/암 · 덤프 토사/암 4줄을 올림. 가중평균 = Σ(토량x거리)/Σ토량 으로 단순평균이 아님. 개별 구간은 근거로 함께 냄 — 어느 구간이 그 평균을 만들었는지 되짚게. 입력은 이미 있는 HaulPlan — 띠·장거리이동에 equipment·haul_distance_m· 지반유형별 물량(ea/rr/br)이 다 있어 다시 세지 않음. ⚠ 무대(free_haul)는 in_bill=False — 품셈 1-2-7 로 품에 포함이고 인력운반 10-6 도 「소운반 20m 초과분」이라 붙일 단가가 없음. 값은 내되 내역 줄만 뺌 — 「무대+도자+덤프 = 총 운반토량」 검산에 씀. 검증 — 28건 통과 (토공집계·설정 19 + 운반 9). 가중평균이 단순평균과 다름을 못 박음(도자 토사 600@40+400@50 → 44m, 45 아님). 울진2·거창5 두 세트가 같은 코드로 서는 것, 무대가 내역 줄이 아닌 것, 구획을 따로 저장해도 상대 값이 안 지워지는 것을 각각 시험함. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
"""운반 가중평균 — 내역 줄이 되는 4줄과 그 근거 (B08 일감 5 · PLAN 8-3·8-7).
|
||
|
||
무엇을 내나
|
||
실무는 **(운반수단 × 지반유형)별 가중평균 1개**를 내역에 올린다. 울진 실측 —
|
||
「도자 토사 1,170㎥ 평균 43.66m · 도자 암 1,554㎥ 39.07m · 덤프 토사 1,667㎥ 293.78m ·
|
||
덤프 암 1,714㎥ 318.6m」로 **4줄**이다. 오솔길도 분류별 가중평균 1개를 낸다
|
||
(거창 무대: 10,399 ÷ 871 = 11.94m).
|
||
|
||
개별 구간 줄은 버리지 않고 **근거**로 함께 낸다 — 어느 구간이 그 평균을 만들었는지
|
||
되짚을 수 있어야 한다.
|
||
|
||
가중평균 = Σ(토량 × 거리) ÷ Σ(토량)
|
||
실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식이다. 단순평균이 아니다.
|
||
|
||
⚠ 무대(`free_haul`)는 내역 줄이 되지 않는다 (PLAN 8-7 ㉡)
|
||
품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다 —
|
||
인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고
|
||
값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다.
|
||
|
||
입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다)
|
||
띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이
|
||
들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Iterable
|
||
|
||
# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다.
|
||
GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
|
||
# 무대 — 품에 포함이라 내역 줄이 되지 않는다.
|
||
FREE_HAUL_KEY = "free_haul"
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class HaulLeg:
|
||
"""근거 줄 하나 — 어느 구간을 얼마나 몇 m 옮겼나."""
|
||
|
||
equipment: str
|
||
ground: str
|
||
volume_m3: float
|
||
distance_m: float
|
||
from_m: float
|
||
to_m: float
|
||
source: str # `band` 또는 `transfer`
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class HaulSummaryRow:
|
||
"""내역 줄 — (운반수단 × 지반유형) 하나."""
|
||
|
||
equipment: str
|
||
ground: str
|
||
volume_m3: float = 0.0
|
||
work_m3m: float = 0.0 # Σ(토량 × 거리) — 가중평균의 분자
|
||
legs: int = 0
|
||
in_bill: bool = True
|
||
|
||
@property
|
||
def average_distance_m(self) -> float:
|
||
return self.work_m3m / self.volume_m3 if self.volume_m3 else 0.0
|
||
|
||
|
||
def _legs_of(plan: dict[str, Any]) -> list[HaulLeg]:
|
||
"""`HaulPlan` → 근거 줄 목록. 띠와 장거리 이동을 같은 모양으로 편다."""
|
||
legs: list[HaulLeg] = []
|
||
|
||
def push(
|
||
item: dict[str, Any],
|
||
equipment: str | None,
|
||
distance: Any,
|
||
source: str,
|
||
from_m: Any,
|
||
to_m: Any,
|
||
) -> None:
|
||
if not equipment or not isinstance(distance, (int, float)):
|
||
return
|
||
for key, label in GROUND_LABELS.items():
|
||
volume = item.get(key)
|
||
if not isinstance(volume, (int, float)) or volume <= 0:
|
||
continue
|
||
legs.append(
|
||
HaulLeg(
|
||
equipment=str(equipment),
|
||
ground=label,
|
||
volume_m3=float(volume),
|
||
distance_m=float(distance),
|
||
from_m=float(from_m or 0.0),
|
||
to_m=float(to_m or 0.0),
|
||
source=source,
|
||
)
|
||
)
|
||
|
||
for block in plan.get("blocks") or []:
|
||
for band in block.get("bands") or []:
|
||
push(
|
||
band,
|
||
band.get("equipment"),
|
||
band.get("haul_distance_m"),
|
||
"band",
|
||
band.get("haul_from_m"),
|
||
band.get("haul_to_m"),
|
||
)
|
||
for transfer in plan.get("transfers") or []:
|
||
push(
|
||
transfer,
|
||
transfer.get("equipment"),
|
||
transfer.get("haul_distance_m"),
|
||
"transfer",
|
||
transfer.get("from_m"),
|
||
transfer.get("to_m"),
|
||
)
|
||
return legs
|
||
|
||
|
||
def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]:
|
||
"""(운반수단 × 지반유형)별 가중평균. 실무 내역이 이 줄들을 그대로 쓴다."""
|
||
grouped: dict[tuple[str, str], HaulSummaryRow] = {}
|
||
for leg in legs:
|
||
key = (leg.equipment, leg.ground)
|
||
row = grouped.get(key)
|
||
if row is None:
|
||
row = HaulSummaryRow(
|
||
equipment=leg.equipment,
|
||
ground=leg.ground,
|
||
in_bill=leg.equipment != FREE_HAUL_KEY,
|
||
)
|
||
grouped[key] = row
|
||
row.volume_m3 += leg.volume_m3
|
||
row.work_m3m += leg.volume_m3 * leg.distance_m
|
||
row.legs += 1
|
||
# 수단 → 지반유형 순으로 안정 정렬 — 화면·내역 줄 순서가 매번 같아야 한다.
|
||
order = {FREE_HAUL_KEY: 0, "dozer": 1, "dump_truck": 2}
|
||
labels = list(GROUND_LABELS.values())
|
||
return sorted(
|
||
grouped.values(),
|
||
key=lambda row: (
|
||
order.get(row.equipment, 9),
|
||
labels.index(row.ground) if row.ground in labels else 9,
|
||
),
|
||
)
|
||
|
||
|
||
def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다."""
|
||
legs = _legs_of(plan or {})
|
||
rows = summarize(legs)
|
||
return {
|
||
"method": "volume_weighted_average",
|
||
"rows": [
|
||
{
|
||
"equipment": row.equipment,
|
||
"ground": row.ground,
|
||
"volume_m3": row.volume_m3,
|
||
"average_distance_m": row.average_distance_m,
|
||
"work_m3m": row.work_m3m,
|
||
"legs": row.legs,
|
||
"in_bill": row.in_bill,
|
||
}
|
||
for row in rows
|
||
],
|
||
# 근거 — 어느 구간이 그 평균을 만들었나. 내역에는 안 오른다.
|
||
"legs": [
|
||
{
|
||
"equipment": leg.equipment,
|
||
"ground": leg.ground,
|
||
"volume_m3": leg.volume_m3,
|
||
"distance_m": leg.distance_m,
|
||
"from_m": leg.from_m,
|
||
"to_m": leg.to_m,
|
||
"source": leg.source,
|
||
}
|
||
for leg in legs
|
||
],
|
||
"bill_row_count": sum(1 for row in rows if row.in_bill),
|
||
}
|
||
|
||
|
||
def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다."""
|
||
return [
|
||
{
|
||
"equipment": row["equipment"],
|
||
"ground": row["ground"],
|
||
"volume_m3": row["volume_m3"],
|
||
"average_distance_m": row["average_distance_m"],
|
||
}
|
||
for row in table.get("rows") or []
|
||
]
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class HaulCheck:
|
||
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
|
||
|
||
hauled_total_m3: float = 0.0
|
||
plan_total_m3: float = 0.0
|
||
difference_m3: float = 0.0
|
||
details: dict[str, float] = field(default_factory=dict)
|
||
|
||
|
||
def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck:
|
||
"""`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가."""
|
||
hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or [])
|
||
plan = plan or {}
|
||
planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0)
|
||
by_equipment: dict[str, float] = {}
|
||
for row in table.get("rows") or []:
|
||
key = str(row.get("equipment"))
|
||
by_equipment[key] = by_equipment.get(key, 0.0) + float(row.get("volume_m3") or 0.0)
|
||
return HaulCheck(
|
||
hauled_total_m3=hauled,
|
||
plan_total_m3=planned,
|
||
difference_m3=hauled - planned,
|
||
details=by_equipment,
|
||
)
|