feat(B06): 담을 사토가 없으면 사토를 새로 세워 구조물 잔토를 담음 (확정 ㉰)

사토가 0 인 노선(전 구간 토취)에서는 잔토를 더할 자리가 없어 **물량이 그냥 사라졌다**.
파낸 흙은 어디로든 가므로 그것이 가장 나쁘다.

- 담을 사토 잔량이 없으면 **그 측점에 사토를 새로 세워** 담는다. 물량이 보존되고
  운반비가 붙는다(금액이 느는 방향 — 안전측).
- ⚠ 토취를 줄이는 길(㉯)은 「그 잔토를 성토재로 쓸 수 있다」는 근거가 있어야 한다.
  구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 하므로, **근거 없이 금액을
  내리지 않는다.** 확정되면 이 함수 한 곳만 바꾸면 된다 — 그 문장을 주석에 남겼다.
- 새로 세운 사토는 자연방토 0 · 지반유형 안분 0 — 실어 내는 흙이고 지반을 모른다.
- 측점별 잔토를 Node 진입점까지 이어 붙임(빠져 있어 총량 갈래로 떨어지고 있었다).

실측(route 184): 사토 0 → **12.88㎥**, 잔량이 **측점 5m 자리에 섬**(전에는 노선 전체 구간).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:50:43 +09:00
co-authored by Claude Opus 5
parent abf586108c
commit 7e41c94be7
2 changed files with 58 additions and 14 deletions
@@ -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
+54 -14
View File
@@ -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 =