diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index 69b54d4b..1dc529e6 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -37,6 +37,8 @@ interface ServerCalcInput { earthwork_conversion?: Parameters[1]; natural_spoil_min_ground_slope?: number | null; haul_equipment_limits?: Parameters[1]; + /** 채집석 공제(㎥, 양수) — B08 이 낸다. `null`/없음은 「아직 안 옴」이다. */ + collected_stone_deduction_m3?: number | null; }; } @@ -52,7 +54,9 @@ const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput; if (input.haul_plan_for) { // **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야 // 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다. - const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits); + const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, { + collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, + }); writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null })); process.exit(0); } @@ -73,7 +77,11 @@ const result = conversion ) : null; // 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06). -const plan = result ? computeHaulPlan(result, input.context?.haul_equipment_limits) : null; +const plan = result + ? computeHaulPlan(result, input.context?.haul_equipment_limits, { + collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, + }) + : null; const massHaul = result ? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null) : null; diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 399ecd57..8e43e6f1 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -63,8 +63,18 @@ _AREA_KEYS = ( ) -def _mass_haul_context() -> dict[str, Any]: - """유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.""" +def _mass_haul_context(collected_stone_deduction_m3: float | None = None) -> dict[str, Any]: + """유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다. + + ⚠ 채집석 공제(`collected_stone_deduction_m3`)만 상수가 아니라 **B08 이 내는 값**이다. + `None` 은 「아직 안 옴」이고 `0` 은 「공제 없음」이라 **서로 다르다** — 값이 안 온 것을 + 공제 0 으로 읽으면 조용히 넘어간다(2026-09-09 네 창 합의). + + 채집석 공제는 사토에서 한 번만 뺀다. + B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, + 빼는 자리는 유토곡선의 사토뿐이다 — + 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. + """ return { "earthwork_conversion": EARTHWORK_CONVERSION_FACTORS, "natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE, @@ -72,6 +82,9 @@ def _mass_haul_context() -> dict[str, Any]: {"key": key, "max_distance_m": limit} for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M ], + # ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다. + # 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다. + "collected_stone_deduction_m3": collected_stone_deduction_m3, } diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 1396df92..cce5d269 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -192,6 +192,13 @@ export interface HaulPlan { borrow_m3: number; /** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */ natural_spoil_m3: number; + /** + * 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 — + * 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09). + */ + collected_stone_deduction_m3: number | null; + /** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */ + collected_stone_deducted_m3: number; /** 블록 안에서 옮기는 양(㎥). */ hauled_m3: number; /** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */ @@ -316,9 +323,54 @@ function bandOutline( * 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다. * 블록도 잔량도 안 나오면(평탄한 곡선) null. */ +/** + * 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구). + * + * 채집석 공제는 사토에서 한 번만 뺀다. + * B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, + * 빼는 자리는 유토곡선의 사토뿐이다 — + * 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. + * + * ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫) + * 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.** + * ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다. + * + * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. + */ +function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: number | null): number { + if (deduction === null || !Number.isFinite(deduction) || deduction <= 0) return 0; + const spoils = residuals.filter((residual) => residual.kind === "spoil"); + let left = deduction; + const take = (residual: HaulResidual, amount: number): void => { + if (amount <= 0) return; + const before = residual.volume_m3; + const ratio = before > 0 ? (before - amount) / before : 0; + residual.volume_m3 = before - amount; + // 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. + residual.ea_m3 *= ratio; + residual.rr_m3 *= ratio; + residual.br_m3 *= ratio; + left -= amount; + }; + // ① 실어 내는 몫부터 + for (const residual of spoils) { + if (left <= EPSILON) break; + take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left)); + } + // ② 모자라면 자연방토에서 + for (const residual of spoils) { + if (left <= EPSILON) break; + const amount = Math.min(residual.natural_m3, left); + residual.natural_m3 -= amount; + take(residual, amount); + } + return deduction - Math.max(left, 0); +} + export function computeHaulPlan( result: MassHaulResult, limits: HaulEquipmentLimit[] | undefined, + options?: { collected_stone_deduction_m3?: number | null }, ): HaulPlan | null { const points = result.points; if (points.length < 2) return null; @@ -497,10 +549,17 @@ export function computeHaulPlan( residual.index = index + 1; }); + const deductionInput = options?.collected_stone_deduction_m3 ?? null; + const deducted = applyCollectedStoneDeduction(settled, deductionInput); + const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON); + remaining.forEach((residual, index) => { + residual.index = index + 1; + }); + let spoil = 0; let borrow = 0; let naturalSpoil = 0; - for (const residual of settled) { + for (const residual of remaining) { if (residual.kind === "spoil") { spoil += residual.volume_m3; naturalSpoil += residual.natural_m3; @@ -510,12 +569,14 @@ export function computeHaulPlan( for (const point of points) fillTotal += point.fill_m3; return { blocks, - residuals: settled, + residuals: remaining, transfers, steps, spoil_m3: spoil, borrow_m3: borrow, natural_spoil_m3: naturalSpoil, + collected_stone_deduction_m3: deductionInput, + collected_stone_deducted_m3: deducted, hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0), transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0), fill_total_m3: fillTotal,