fix(B06): 채집석 공제도 축을 맞춰 뺌 — 갈래마다 ×C

채집석은 **벽 입적(제자리 부피)**이라 자연 축이고 유토곡선은 다짐 축임.
축이 다른 값을 그냥 빼고 있었음(리핑암이면 13% 어긋남).

- 갈래별 값(`collected_stone_by_ground_m3`)이 오면 갈래마다 ×C 해서 뺌
- 갈래를 못 가른 몫은 계수가 없어 **환산하지 않고** 그대로 뺌
- 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺌 (안 온 것과 0 은 다름)
- 통로 정리 — `_mass_haul_context` 가 B08 입력 dict 를 통째로 받게 함
  (칸이 늘 때마다 인자를 늘리다 빠뜨리던 자리)

⚠ 벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없음 — 그 가정은 그대로 두고
**축만** 맞춘 것임(세 창 합의, 근거 문구는 B08 이 값과 함께 보냄).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 07:27:02 +09:00
co-authored by Claude Opus 5
parent 652dbcbd12
commit 1379b7fd69
4 changed files with 73 additions and 23 deletions
+1 -5
View File
@@ -70,11 +70,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(
haul_inputs.get("collected_stone_deduction_m3"),
haul_inputs.get("structure_spoil_m3"),
haul_inputs.get("structure_spoil_points"),
),
"context": _mass_haul_context(haul_inputs),
},
)
except Exception:
@@ -39,6 +39,10 @@ interface ServerCalcInput {
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[1];
/** 채집석 공제(㎥, 양수) — B08 이 낸다. `null`/없음은 「아직 안 옴」이다. */
collected_stone_deduction_m3?: number | null;
/** 갈래별 채집석(㎥) — 벽 입적(자연 축)이라 곡선 쪽이 ×C 해 다짐 축에 맞춘다. */
collected_stone_by_ground_m3?: Record<string, number> | null;
/** 갈래를 못 가른 채집석(㎥) — 계수가 없어 환산하지 않는다. */
collected_stone_ground_unknown_m3?: number | null;
/** 구조물 터파기 잔토(㎥, 양수) — B08 이 낸다. 사토에 **더한다**. */
structure_spoil_m3?: number | null;
/** 측점별 잔토 — 오면 **그 자리**에 얹는다(운반거리가 맞다). */
@@ -67,6 +71,8 @@ if (input.haul_plan_for) {
// 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다.
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
@@ -95,6 +101,8 @@ const result = conversion
const plan = result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
+10 -13
View File
@@ -79,11 +79,7 @@ async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
return {}
def _mass_haul_context(
collected_stone_deduction_m3: float | None = None,
structure_spoil_m3: float | None = None,
structure_spoil_points: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
⚠ 채집석 공제(`collected_stone_deduction_m3`)만 상수가 아니라 **B08 이 내는 값**이다.
@@ -95,6 +91,7 @@ def _mass_haul_context(
빼는 자리는 유토곡선의 사토뿐이다 —
실어 내는 몫(spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
"""
inputs = haul_inputs or {}
return {
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
@@ -104,14 +101,18 @@ def _mass_haul_context(
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
"collected_stone_deduction_m3": collected_stone_deduction_m3,
"collected_stone_deduction_m3": inputs.get("collected_stone_deduction_m3"),
# 갈래별 채집석(2026-09-09 세 창 확정) — **벽 입적**이라 자연 축으로 보고 곡선 쪽에서
# ×C 해 다짐 축에 맞춰 뺀다. 갈래를 못 가른 몫은 계수가 없어 **환산하지 않는다**.
"collected_stone_by_ground_m3": inputs.get("collected_stone_by_ground_m3") or {},
"collected_stone_ground_unknown_m3": inputs.get("collected_stone_ground_unknown_m3"),
# 구조물 터파기 잔토(㎥, 양수) — **사토에 더한다**(공제는 빼고 이것은 더한다).
# 구조물 잔토는 사토에 한 번만 더한다.
# B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
# 더하는 자리는 유토곡선의 사토뿐이다.
"structure_spoil_m3": structure_spoil_m3,
"structure_spoil_m3": inputs.get("structure_spoil_m3"),
# 측점별 잔토 — 오면 이쪽이 이긴다(구조물이 선 자리 잔량에 얹어 운반거리를 맞춘다).
"structure_spoil_points": structure_spoil_points,
"structure_spoil_points": inputs.get("structure_spoil_points"),
}
@@ -191,11 +192,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
_NPM_SCRIPT,
{
"detail": detail,
"context": _mass_haul_context(
haul_inputs.get("collected_stone_deduction_m3"),
haul_inputs.get("structure_spoil_m3"),
haul_inputs.get("structure_spoil_points"),
),
"context": _mass_haul_context(haul_inputs),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
+54 -5
View File
@@ -348,10 +348,22 @@ function bandOutline(
*
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
*/
function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: number | null): number {
if (deduction === null || !Number.isFinite(deduction) || deduction <= 0) return 0;
function applyCollectedStoneDeduction(
residuals: HaulResidual[],
deduction: number | null,
byGround?: Record<string, number> | null,
groundUnknownM3?: number | null,
conversion?: EarthworkConversion | null,
): number {
// ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라
// 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다.
// 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다).
// 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다.
const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion);
const amountToTake = converted === null ? deduction : converted;
if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0;
const spoils = residuals.filter((residual) => residual.kind === "spoil");
let left = deduction;
let left = amountToTake;
const take = (residual: HaulResidual, amount: number): void => {
if (amount <= 0) return;
const before = residual.volume_m3;
@@ -375,7 +387,7 @@ function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: numb
residual.natural_m3 -= amount;
take(residual, amount);
}
return deduction - Math.max(left, 0);
return amountToTake - Math.max(left, 0);
}
/**
@@ -417,6 +429,33 @@ const GROUND_OF_BUCKET = {
br_m3: "blasting_rock",
} as const;
/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */
const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const;
/**
* 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로.
* 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다.
*/
function compactedStoneDeduction(
byGround: Record<string, number> | null | undefined,
groundUnknownM3: number | null | undefined,
conversion: EarthworkConversion | null | undefined,
): number | null {
if (!byGround) return null;
const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter(
([, value]) => Number.isFinite(value) && value > 0,
);
const unknown = Number(groundUnknownM3 ?? 0);
const hasUnknown = Number.isFinite(unknown) && unknown > 0;
if (!entries.length && !hasUnknown) return null;
let total = hasUnknown ? unknown : 0;
for (const [ground, value] of entries) {
const factor = conversion?.[ground]?.compacted;
total += typeof factor === "number" && factor > 0 ? value * factor : value;
}
return total;
}
/**
* **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다.
*
@@ -545,6 +584,10 @@ export function computeHaulPlan(
limits: HaulEquipmentLimit[] | undefined,
options?: {
collected_stone_deduction_m3?: number | null;
/** 갈래별 채집석(㎥) — **벽 입적(자연 축)**이라 여기서 ×C 해 다짐 축에 맞춘다. */
collected_stone_by_ground_m3?: Record<string, number> | null;
/** 갈래를 못 가른 채집석(㎥) — 계수가 없어 **환산하지 않고** 그대로 뺀다. */
collected_stone_ground_unknown_m3?: number | null;
structure_spoil_m3?: number | null;
/** 측점별 구조물 잔토 — 오면 **이쪽이 이긴다**(그 자리 잔량에 얹어 운반거리를 맞춘다). */
structure_spoil_points?: Array<{
@@ -752,7 +795,13 @@ export function computeHaulPlan(
options?.conversion ?? null,
);
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput);
const deducted = applyCollectedStoneDeduction(
settled,
deductionInput,
options?.collected_stone_by_ground_m3 ?? null,
options?.collected_stone_ground_unknown_m3 ?? null,
options?.conversion ?? null,
);
const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON);
remaining.forEach((residual, index) => {
residual.index = index + 1;