Files
Aislo/B08_Quantity/B08_Quantity_Engine_HaulSummary.py
T
eomsangdonandClaude Opus 5 9badcbf80e feat(B08): 토공집계표·운반 가중평균 엔진 + 프로젝트 설정 자리
일감 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>
2026-09-07 20:54:23 +09:00

218 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""운반 가중평균 — 내역 줄이 되는 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,
)