diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index dbcd4d4b..e4cb1e78 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -41,6 +41,8 @@ interface ServerCalcInput { collected_stone_deduction_m3?: number | null; /** 구조물 터파기 잔토(㎥, 양수) — B08 이 낸다. 사토에 **더한다**. */ structure_spoil_m3?: number | null; + /** 측점별 잔토 — 오면 **그 자리**에 얹는다(운반거리가 맞다). */ + structure_spoil_points?: Array<{ chainage_m: number; spoil_m3: number }> | null; }; } @@ -59,6 +61,7 @@ if (input.haul_plan_for) { const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, { collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, + structure_spoil_points: input.context?.structure_spoil_points ?? null, }); writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null })); process.exit(0); @@ -84,6 +87,7 @@ const plan = result ? computeHaulPlan(result, input.context?.haul_equipment_limits, { collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null, structure_spoil_m3: input.context?.structure_spoil_m3 ?? null, + structure_spoil_points: input.context?.structure_spoil_points ?? null, }) : null; const massHaul = result diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 6057b2d8..81c9b6bb 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -391,42 +391,82 @@ function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: numb * * 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다. */ +function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual { + // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰). + // ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다. + return { + index: 0, + kind: "spoil", + from_m: fromM, + to_m: toM, + volume_m3: volumeM3, + level_from_m3: 0, + level_to_m3: 0, + ea_m3: 0, + rr_m3: 0, + br_m3: 0, + natural_m3: 0, + }; +} + +/** + * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). + * + * 구조물 잔토는 사토에 한 번만 더한다. + * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, + * 더하는 자리는 유토곡선의 사토뿐이다. + * + * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. + * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 + * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 + * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — + * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. + * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. + * ⚠ 자연방토·지반유형 안분은 안 건드린다 — 실어 내는 흙이고, 어느 지반에서 나온지 모른다. + */ function applyStructureSpoil( residuals: HaulResidual[], amount: number | null, points: Array<{ chainage_m: number; spoil_m3: number }> | null, ): number { - const spoils = residuals.filter((residual) => residual.kind === "spoil"); - if (!spoils.length) return 0; + const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil"); - // ① 측점별 값이 오면 **그 자리 잔량**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다. + // ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다. if (points && points.length) { let added = 0; for (const point of points) { const value = Number(point?.spoil_m3); const chainage = Number(point?.chainage_m); if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue; - // 그 측점을 품은 잔량 → 없으면 가장 가까운 잔량. 버리지 않는다. + const spoils = spoilsOf(); const covering = spoils.find( (residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9, ); - const target = - covering ?? - spoils.reduce((best, residual) => { - const middle = (residual.from_m + residual.to_m) / 2; - const bestMiddle = (best.from_m + best.to_m) / 2; - return Math.abs(chainage - middle) < Math.abs(chainage - bestMiddle) ? residual : best; - }, spoils[0]); - target.volume_m3 += value; + if (covering) { + covering.volume_m3 += value; + } else { + // 그 자리를 품는 사토가 없다 — 그 측점에 사토를 새로 세워 담는다. + residuals.push(newSpoilResidual(chainage, chainage, value)); + } added += value; } return added; } - // ② 총량만 오면 **남은 사토 잔량 크기에 비례**해 나눈다(어느 자리인지 모를 때의 차선). + // ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선). if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0; + const spoils = spoilsOf(); const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0); - if (total <= EPSILON) return 0; + if (!spoils.length || total <= EPSILON) { + // 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로 + // 구간을 넓게 잡고, **물량은 보존**한다. + const froms = residuals.map((residual) => residual.from_m); + const tos = residuals.map((residual) => residual.to_m); + const fromM = froms.length ? Math.min(...froms) : 0; + const toM = tos.length ? Math.max(...tos) : 0; + residuals.push(newSpoilResidual(fromM, toM, amount)); + return amount; + } let added = 0; spoils.forEach((residual, index) => { const share =