/* ============================================================================= * common_util_mass_haul_settle.ts * 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화. * * `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와 * 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다. * * 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만). * 타입 import는 실행 시 사라지므로 순환이 생기지 않는다. * ========================================================================== */ import type { EarthworkConversion, GroundType } from "./common_util_mass_haul_types"; import type { MassHaulPoint } from "./common_util_mass_haul"; import type { HaulBand, HaulEquipmentLimit, HaulPlan, HaulResidual, HaulTransfer, } from "./common_util_mass_haul_balance"; import { EPSILON } from "./common_util_mass_haul_curve"; /** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ function factorFor(conversion: EarthworkConversion, ground: GroundType): number { const factor = conversion?.[ground]?.compacted; return Number.isFinite(factor) && factor > 0 ? factor : 1; } export interface CutMix { ea: number; rr: number; br: number; } /** * `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만 * 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다. */ export function cutMix( points: MassHaulPoint[], fromX: number, toX: number, conversion: EarthworkConversion, ): CutMix { const mix: CutMix = { ea: 0, rr: 0, br: 0 }; for (let index = 1; index < points.length; index += 1) { const a = points[index - 1].chainage_m; const b = points[index].chainage_m; const span = b - a; if (span <= 0) continue; const overlap = Math.min(b, toX) - Math.max(a, fromX); if (overlap <= 0) continue; const ratio = overlap / span; mix.ea += points[index].cut_soil_m3 * ratio * factorFor(conversion, "soil"); mix.rr += points[index].cut_rr_m3 * ratio * factorFor(conversion, "ripping_rock"); mix.br += points[index].cut_br_m3 * ratio * factorFor(conversion, "blasting_rock"); } return mix; } /** * `[fromX, toX]` 중 자연방토가 가능한 구간의 **길이 비율**(0~1). * 구간마다 양 끝 측점이 둘 다 가능해야 그 구간을 인정한다(`natural_spoil`). */ export function naturalSpoilRatio(points: MassHaulPoint[], fromX: number, toX: number): number { let total = 0; let allowed = 0; for (let index = 1; index < points.length; index += 1) { const a = points[index - 1].chainage_m; const b = points[index].chainage_m; const overlap = Math.min(b, toX) - Math.max(a, fromX); if (overlap <= 0) continue; total += overlap; if (points[index].natural_spoil) allowed += overlap; } return total > 0 ? allowed / total : 0; } /** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */ export function apportion( mix: CutMix, volume: number, ): Pick { const total = mix.ea + mix.rr + mix.br; if (!(total > 0)) return { ea_m3: volume, rr_m3: 0, br_m3: 0 }; return { ea_m3: (mix.ea / total) * volume, rr_m3: (mix.rr / total) * volume, br_m3: (mix.br / total) * volume, }; } /** 거리 짧은 장비부터. `max_distance_m: null`(상한 없음)은 항상 맨 뒤. */ export function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLimit[] { if (!limits?.length) return [{ key: "", max_distance_m: null }]; return [...limits].sort((a, b) => { if (a.max_distance_m === null) return 1; if (b.max_distance_m === null) return -1; return a.max_distance_m - b.max_distance_m; }); } /** * 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가 * 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면 * 프론트에 사본을 두지 않고 그냥 비운다. */ export function pickEquipment( limits: HaulEquipmentLimit[] | undefined, distanceM: number, ): string | null { if (!limits?.length) return null; for (const limit of sortedLimits(limits)) { if (limit.max_distance_m === null || distanceM <= limit.max_distance_m) return limit.key || null; } return null; } /** * 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다 * (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만 * 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다. * * `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다. */ export function settleResiduals( points: MassHaulPoint[], residuals: HaulResidual[], conversion: EarthworkConversion, limits: HaulEquipmentLimit[] | undefined, ): HaulTransfer[] { const center = (residual: HaulResidual): number => (residual.from_m + residual.to_m) / 2; const level = (residual: HaulResidual): number => (residual.level_from_m3 + residual.level_to_m3) / 2; const transfers: HaulTransfer[] = []; const pairs: Array<{ spoil: HaulResidual; borrow: HaulResidual; distance: number }> = []; for (const spoil of residuals.filter((entry) => entry.kind === "spoil")) { for (const borrow of residuals.filter((entry) => entry.kind === "borrow")) { pairs.push({ spoil, borrow, distance: Math.abs(center(spoil) - center(borrow)) }); } } pairs.sort((a, b) => a.distance - b.distance); for (const pair of pairs) { const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3); if (!(volume > EPSILON)) continue; // 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0; pair.spoil.ea_m3 *= ratio; pair.spoil.rr_m3 *= ratio; pair.spoil.br_m3 *= ratio; pair.spoil.natural_m3 *= ratio; pair.spoil.volume_m3 -= volume; pair.borrow.volume_m3 -= volume; // 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다. const mix = cutMix(points, pair.spoil.from_m, pair.spoil.to_m, conversion); transfers.push({ index: transfers.length + 1, from_m: center(pair.spoil), to_m: center(pair.borrow), volume_m3: volume, haul_distance_m: pair.distance, level_m3: (level(pair.spoil) + level(pair.borrow)) / 2, equipment: pickEquipment(limits, pair.distance), ...apportion(mix, volume), }); } return transfers; } /** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */ export function haulPlanPayload(plan: HaulPlan): Record { const round = (value: number): number => Math.round(value * 100) / 100; return { spoil_m3: round(plan.spoil_m3), natural_spoil_m3: round(plan.natural_spoil_m3), borrow_m3: round(plan.borrow_m3), hauled_m3: round(plan.hauled_m3), transferred_m3: round(plan.transferred_m3), fill_total_m3: round(plan.fill_total_m3), // 구조물 몫 — **받은 값**과 **실제로 먹은 값**을 함께 남긴다. 「통로만 있고 값이 안 흐른다」를 // 저장분에서 바로 가릴 수 있어야 한다(2026-09-09 그 사고가 세 번 났다). // `null` 은 「아직 안 옴」, `0` 은 「없음」이다 — 눅이지 않는다. collected_stone_deduction_m3: plan.collected_stone_deduction_m3, collected_stone_deducted_m3: round(plan.collected_stone_deducted_m3), structure_spoil_m3: plan.structure_spoil_m3, structure_spoil_added_m3: round(plan.structure_spoil_added_m3), // 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다. transfers: plan.transfers.map((transfer) => ({ index: transfer.index, from_m: round(transfer.from_m), to_m: round(transfer.to_m), volume_m3: round(transfer.volume_m3), haul_distance_m: round(transfer.haul_distance_m), equipment: transfer.equipment, ea_m3: round(transfer.ea_m3), rr_m3: round(transfer.rr_m3), br_m3: round(transfer.br_m3), })), blocks: plan.blocks.map((block) => ({ index: block.index, from_m: round(block.from_m), to_m: round(block.to_m), base_m3: round(block.base_m3), volume_m3: round(block.volume_m3), direction: block.direction, // 장비 띠 — B08 내역서는 이 단위로 운반 항목을 세운다. bands: block.bands.map((band) => ({ index: band.index, equipment: band.equipment, volume_m3: round(band.volume_m3), haul_distance_m: round(band.haul_distance_m), ea_m3: round(band.ea_m3), rr_m3: round(band.rr_m3), br_m3: round(band.br_m3), // 띠의 기하 — B07 토적도가 경계현·평균운반거리 현을 이 값으로 그린다. // 여기서 함께 저장하지 않으면 파이썬 쪽에 곡선 보간·배분 로직을 복제해야 한다. level_base_m3: round(band.level_base_m3), level_apex_m3: round(band.level_apex_m3), boundary_from_m: round(band.boundary_from_m), boundary_to_m: round(band.boundary_to_m), haul_from_m: round(band.haul_from_m), haul_to_m: round(band.haul_to_m), })), })), residuals: plan.residuals.map((residual) => ({ index: residual.index, kind: residual.kind, // 평형선 단차 — 토적도 balloon을 곡선 옆 제 높이에 놓는 데 쓴다. level_from_m3: round(residual.level_from_m3), level_to_m3: round(residual.level_to_m3), from_m: round(residual.from_m), to_m: round(residual.to_m), volume_m3: round(residual.volume_m3), natural_m3: round(residual.natural_m3), ea_m3: round(residual.ea_m3), rr_m3: round(residual.rr_m3), br_m3: round(residual.br_m3), // ⚠ **지반을 모르는 몫**(㎥) — 갈래 합과 총량의 차이다. 지금은 **구조물 잔토**가 // 그것이다(어느 지반에서 파낸 흙인지 우리가 판정하지 않는다). 받는 쪽이 덤프 단가를 // 토사/암으로 가를 때 **이 몫만 근거가 없다**는 것을 알아야 하므로 값으로 낸다 // (2026-09-09 — 안 내면 0 으로 눅여 토사로 세기 쉽다). ground_unknown_m3: round( Math.max(residual.volume_m3 - (residual.ea_m3 + residual.rr_m3 + residual.br_m3), 0), ), })), }; }