Files
Aislo/common_util/common_util_mass_haul_balance.ts
T
eomsangdonandClaude Sonnet 5.5 381f3d0573 refactor(512-5 700 줄 나눔 TS): 유토 배분 결과 타입 · 사토 잔토 · 공제 · 종단 표 고른 측점 열을 새 파일로 — 동작 · 값 그대로
- common_util_mass_haul_balance 842 → 467 (_types · _apply 분리 · 원래 파일에서 타입 다시 내보냄)
- B05_Profile_UI_Profile_Table 702 → 548 (_Table_Selected 분리)
- test_507_3 는 옮긴 글을 새 파일에서 찾음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:46:32 +09:00

470 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* common_util_mass_haul_balance.ts
* 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서
* **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 →
* 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분.
*
* ── 작도 규칙(참고자료) ─────────────────────────────────────────────
* <https://clouds-daily.tistory.com/249>
* - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계.
* - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반.
* - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량.
* - 운반토량 = 평형선에서 곡선 정점까지의 종거.
* - 평균운반거리 = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선).
* - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반.
*
* ── 평형선을 어떻게 자동으로 잡는가 ─────────────────────────────────
* 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. **0선 하나로 고정하면
* 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을
* 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**:
*
* 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다.
* - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다.
* 평형선은 그대로, 블록은 [커서 … 곡선이 평형선으로 되돌아온 점].
* - 올라갔다가 현재 높이 **위**에서 멈추면 → 남는 절토가 생긴다. 평형선을 그 골 높이로
* 올리고, 못 받은 만큼을 **그 자리에서** 사토로 계상한다.
* - 내려가는 경우는 정확히 대칭(토취).
*
* 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며
* ③ 단차의 합이 최종 누가토량과 정확히 일치한다.
*
* ── 블록 하나를 장비 띠로 가르는 이유 ───────────────────────────────
* 참고 도면의 토량배분도에는 수평선이 블록마다 여러 개 그려져 있고(A–A′ / B–B′ / C–C′),
* 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 1:1로 대응한다. 하단 브래킷
* (무대운반거리 / 도자 평균운반거리 / 덤프 평균운반거리)의 폭이 각 수평선의 길이와 같다.
*
* 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량
* 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다.
* 현 길이 20m 되는 높이 위쪽 → 종무대
* 거기서 60m 되는 높이까지 → 도쟈
* 그 아래 ~ 평형선 → 덤프
* 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다.
*
* ── 단위 ─────────────────────────────────────────────────────────
* 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은
* `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다.
* ========================================================================== */
import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul";
import type { EarthworkConversion } from "./common_util_mass_haul_types";
import {
crossFrom,
EPSILON,
evaluate,
extremaIndices,
pruneExtrema,
segmentOf,
} from "./common_util_mass_haul_curve";
import {
apportion,
cutMix,
naturalSpoilRatio,
settleResiduals,
sortedLimits,
} from "./common_util_mass_haul_settle";
export { haulPlanPayload } from "./common_util_mass_haul_settle";
import type {
BalanceStep,
CurvePoint,
HaulBand,
HaulBlock,
HaulDirection,
HaulEquipmentLimit,
HaulPlan,
HaulResidual,
} from "./common_util_mass_haul_balance_types";
import {
applyCollectedStoneDeduction,
applyStructureSpoil,
} from "./common_util_mass_haul_balance_apply";
export type {
BalanceStep,
CurvePoint,
HaulBand,
HaulBlock,
HaulDirection,
HaulEquipmentLimit,
HaulPlan,
HaulResidual,
HaulTransfer,
} from "./common_util_mass_haul_balance_types";
/**
* **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면
* balloon이 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
* ⚠ 수량 계획에는 걸지 않는다 — 진폭이 커지면 작은 봉우리가 통째로 지워져 운반량이 0 이 됐다
* (2026-09-14 936be972 실측 474.14㎥ → 0 · 브레인 ①).
*/
const MIN_SWING_RATIO = 0.02;
/** 경계현 높이를 훑을 때 쪼개는 칸 수. 잔요철에 속지 않을 만큼 촘촘해야 한다. */
const CHORD_SCAN_STEPS = 64;
/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */
const CHORD_SOLVE_STEPS = 24;
/** 띠 테두리를 곡선(포물선)에 붙이려고 구간 하나를 쪼개는 수. */
const OUTLINE_STEPS = 4;
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
interface BlockGeometry {
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
levelAt: (ratio: number) => number;
/** 그 높이 수평선이 블록 안에서 곡선을 자르는 두 점. */
chordAt: (ratio: number) => { from: number; to: number; length: number };
}
function blockGeometry(
points: MassHaulPoint[],
fromM: number,
toM: number,
apexM: number,
base: number,
apex: number,
): BlockGeometry {
const levelAt = (ratio: number): number => base + (apex - base) * ratio;
const chordAt = (ratio: number): { from: number; to: number; length: number } => {
const level = levelAt(ratio);
const from = crossFrom(points, fromM, apexM, level) ?? fromM;
const to = crossFrom(points, apexM, toM, level) ?? toM;
return { from, to, length: Math.max(to - from, 0) };
};
return { levelAt, chordAt };
}
/**
* 현의 길이가 `target` **이하로 확정되는** 가장 낮은 높이를 찾는다(반환값은 `ratio`).
*
* 현 길이는 평형선(가장 김)에서 정점(0)으로 갈수록 줄지만 **단조가 아니다** — 가지치기로
* 블록을 하나로 묶어도 그 안의 원래 폴리라인에는 잔요철이 남아, 높이를 올리는 도중 현이
* 잠깐 다시 길어질 수 있다. 이분법만 쓰면 그 지점에서 엉뚱한 근으로 수렴한다(무작위 검증에서
* 종무대 경계현이 45m로 잡히는 사례를 잡았다).
*
* 그래서 **훑기 → 이분법** 두 단계로 간다: 먼저 전 구간을 성기게 훑어 현이 `target`을 넘는
* **가장 높은** 자리를 찾고, 그 바로 위 칸에서만 이분법으로 좁힌다. 이렇게 잡은 경계 위쪽은
* 전부 `target` 이하가 보장된다 — "이 띠는 이 장비로 옮길 수 있다"가 성립해야 하기 때문이다.
* 평형선에서 이미 `target` 이하면 그 장비가 블록 전체를 맡는다 → 0.
*/
function ratioForChord(geometry: BlockGeometry, target: number): number {
let lastOver = -1;
for (let step = 0; step <= CHORD_SCAN_STEPS; step += 1) {
if (geometry.chordAt(step / CHORD_SCAN_STEPS).length > target) lastOver = step;
}
if (lastOver < 0) return 0;
if (lastOver >= CHORD_SCAN_STEPS) return 1;
let low = lastOver / CHORD_SCAN_STEPS;
let high = (lastOver + 1) / CHORD_SCAN_STEPS;
for (let step = 0; step < CHORD_SOLVE_STEPS; step += 1) {
const middle = (low + high) / 2;
if (geometry.chordAt(middle).length > target) low = middle;
else high = middle;
}
return high;
}
/**
* 띠를 감싸는 다각형.
*
* 예전에는 「아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선」을 이어 붙였는데, 곡선이 두 현 사이에서
* 요동치면 그 조각이 띠 밖으로 삐져나가 **면이 스스로 겹쳤다**(2026-08-02 사용자 지적).
*
* 정의를 바꿨다. 띠는 「아래 현의 두 끝 사이에서, 아래 높이부터 **곡선과 위 높이 중 안쪽**까지」다.
* 아래 현을 밑변으로 깔고 그 위를 훑으며 곡선값을 두 높이 사이로 **자른 값**만 찍는다.
* 이러면 어떤 지형에서도 단순 다각형이라 스스로 겹칠 수가 없다.
*/
function bandOutline(
points: MassHaulPoint[],
low: { from: number; to: number },
lowLevel: number,
highLevel: number,
): CurvePoint[] {
const floor = Math.min(lowLevel, highLevel);
const ceil = Math.max(lowLevel, highLevel);
const outline: CurvePoint[] = [{ m: low.from, v: lowLevel }];
const push = (m: number, v: number): void => {
const previous = outline[outline.length - 1];
if (previous && Math.abs(previous.m - m) < 1e-9 && Math.abs(previous.v - v) < 1e-9) return;
outline.push({ m, v });
};
for (let index = 1; index < points.length; index += 1) {
const segment = segmentOf(points, index);
if (!segment) continue;
const start = Math.max(segment.x0, low.from);
const end = Math.min(segment.x0 + segment.span, low.to);
if (!(end > start)) continue;
for (let step = 0; step <= OUTLINE_STEPS; step += 1) {
const m = start + ((end - start) * step) / OUTLINE_STEPS;
push(m, Math.min(Math.max(evaluate(segment, m - segment.x0), floor), ceil));
}
}
push(low.to, lowLevel);
return outline;
}
/**
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
*/
export function computeHaulPlan(
result: MassHaulResult,
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<{
chainage_m: number;
spoil_m3: number;
/** 그 터파기의 토질 — B08 이 `design.ground_type` 으로 이미 판정한 값(품셈 9-13 3구분).
* **새 근거를 만드는 것이 아니라 이어받는 것**이다. 섞여서 못 고른 구조물은 `null` 로
* 오고, 그 몫은 「지반 모름」(`ground_unknown_m3`)으로 남는다. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null;
/** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다.
* 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */
conversion?: EarthworkConversion | null;
/** 그림용 — 잔진동을 거른다(`MIN_SWING_RATIO`). 안 오면 **거르지 않는다**(수량 계획). */
drawing?: boolean;
},
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
const extrema = options?.drawing
? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1))
: extremaIndices(points);
const tiers = sortedLimits(limits);
const blocks: HaulBlock[] = [];
const residuals: HaulResidual[] = [];
const steps: BalanceStep[] = [];
let bandCounter = 0;
const pushBlock = (
fromM: number,
toM: number,
base: number,
apexM: number,
apex: number,
direction: HaulDirection,
): void => {
const volume = Math.abs(apex - base);
if (!(volume > EPSILON) || !(toM > fromM)) return;
const geometry = blockGeometry(points, fromM, toM, apexM, base, apex);
// 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다.
const bands: HaulBand[] = [];
let upper = 1;
for (const tier of tiers) {
if (upper <= EPSILON) break;
const lower = tier.max_distance_m === null ? 0 : ratioForChord(geometry, tier.max_distance_m);
if (upper - lower <= 1e-6) {
upper = lower;
continue;
}
const lowChord = geometry.chordAt(lower);
const highChord = geometry.chordAt(upper);
const haulChord = geometry.chordAt((lower + upper) / 2);
// 절토 쪽 변에서 잘라 낸 구간이 이 띠가 퍼오는 흙이다(산이면 왼쪽 변, 골이면 오른쪽 변).
const cutFrom = direction === "forward" ? lowChord.from : highChord.to;
const cutTo = direction === "forward" ? highChord.from : lowChord.to;
const bandVolume = (upper - lower) * volume;
bandCounter += 1;
bands.push({
index: bandCounter,
equipment: tier.key || null,
level_base_m3: geometry.levelAt(lower),
level_apex_m3: geometry.levelAt(upper),
volume_m3: bandVolume,
boundary_from_m: lowChord.from,
boundary_to_m: lowChord.to,
haul_distance_m: haulChord.length,
haul_from_m: haulChord.from,
haul_to_m: haulChord.to,
outline: bandOutline(points, lowChord, geometry.levelAt(lower), geometry.levelAt(upper)),
...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume),
});
upper = lower;
}
blocks.push({
index: blocks.length + 1,
base_m3: base,
from_m: fromM,
to_m: toM,
apex_m: apexM,
apex_volume_m3: apex,
volume_m3: volume,
direction,
bands,
});
steps.push({ from_m: fromM, to_m: toM, level_m3: base });
};
const pushResidual = (fromM: number, toM: number, from: number, to: number): void => {
const delta = to - from;
if (Math.abs(delta) < EPSILON) return;
const volume = Math.abs(delta);
residuals.push({
index: residuals.length + 1,
kind: delta > 0 ? "spoil" : "borrow",
from_m: fromM,
to_m: toM,
volume_m3: volume,
level_from_m3: from,
level_to_m3: to,
// 자연방토는 사토에만 해당한다 — 토취는 밖에서 사 오는 흙이다.
natural_m3: delta > 0 ? volume * naturalSpoilRatio(points, fromM, toM) : 0,
// 사토만 지반유형을 물을 수 있다(절토에서 남은 흙). 토취는 밖에서 사 온다.
...(delta > 0
? apportion(cutMix(points, fromM, toM, result.conversion), volume)
: { ea_m3: 0, rr_m3: 0, br_m3: 0 }),
});
};
let level = points[0].cumulative_volume_m3;
let cursor = points[0].chainage_m;
let k = 1;
while (k < extrema.length) {
const apexX = points[extrema[k]].chainage_m;
const apexY = points[extrema[k]].cumulative_volume_m3;
if (apexX <= cursor + EPSILON) {
k += 1;
continue;
}
const rising = apexY > level + EPSILON;
const falling = apexY < level - EPSILON;
if (!rising && !falling) {
cursor = apexX;
k += 1;
continue;
}
// 짝이 되는 다음 극값이 없으면 받아 줄 성토(또는 절토)가 없다 — 전량 사토/토취.
if (k + 1 >= extrema.length) {
steps.push({ from_m: cursor, to_m: apexX, level_m3: level });
pushResidual(cursor, apexX, level, apexY);
level = apexY;
cursor = apexX;
k += 1;
continue;
}
const nextX = points[extrema[k + 1]].chainage_m;
const nextY = points[extrema[k + 1]].cumulative_volume_m3;
if (rising) {
if (nextY <= level + EPSILON) {
// 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다.
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
pushBlock(cursor, end, level, apexX, apexY, "forward");
cursor = end;
k += 1;
} else {
// 못 받은 절토가 남는다 — 평형선을 그 골 높이로 올리고 차액을 사토로 계상한다.
const start = crossFrom(points, cursor, apexX, nextY) ?? cursor;
steps.push({ from_m: cursor, to_m: start, level_m3: level });
pushResidual(cursor, start, level, nextY);
pushBlock(start, nextX, nextY, apexX, apexY, "forward");
level = nextY;
cursor = nextX;
k += 2;
}
} else if (nextY >= level - EPSILON) {
// 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반).
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
pushBlock(cursor, end, level, apexX, apexY, "backward");
cursor = end;
k += 1;
} else {
// 절토가 모자라 성토를 못 채운다 — 평형선을 내리고 차액을 토취로 계상한다.
const start = crossFrom(points, cursor, apexX, nextY) ?? cursor;
steps.push({ from_m: cursor, to_m: start, level_m3: level });
pushResidual(cursor, start, level, nextY);
pushBlock(start, nextX, nextY, apexX, apexY, "backward");
level = nextY;
cursor = nextX;
k += 2;
}
}
// 극값 가지치기로 잘려 나간 꼬리가 있으면 총량이 어긋난다 — 종점 잔량으로 흡수한다.
const endX = points[points.length - 1].chainage_m;
const endY = points[points.length - 1].cumulative_volume_m3;
if (Math.abs(endY - level) > EPSILON) {
steps.push({ from_m: cursor, to_m: endX, level_m3: level });
pushResidual(cursor, endX, level, endY);
}
if (!blocks.length && !residuals.length) return null;
const transfers = settleResiduals(points, residuals, result.conversion, limits);
const settled = residuals.filter((residual) => residual.volume_m3 > EPSILON);
settled.forEach((residual, index) => {
residual.index = index + 1;
});
// ⚠ **순서가 뜻을 가른다 — 「잔토를 더하고 → 공제를 뺀다」**(2026-09-09 확정).
// 유토곡선의 사토는 「현장에 남는 흙 총량」이고 **출처를 안 가린다**. 공제는 그 총량에서
// 빼는 것이라 **잔토가 담긴 뒤라야 뺄 대상이 있다.** 반대 순서로 두면 사토가 0 인
// 노선(전 구간 토취)에서 공제가 **영영 안 걸린다** — 두 창 저장분에서 실제로 그랬다.
const structureSpoilInput = options?.structure_spoil_m3 ?? null;
const structureSpoilPoints = options?.structure_spoil_points ?? null;
const structureSpoilAdded = applyStructureSpoil(
settled,
structureSpoilInput,
structureSpoilPoints,
options?.conversion ?? null,
);
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
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;
});
let spoil = 0;
let borrow = 0;
let naturalSpoil = 0;
for (const residual of remaining) {
if (residual.kind === "spoil") {
spoil += residual.volume_m3;
naturalSpoil += residual.natural_m3;
} else borrow += residual.volume_m3;
}
let fillTotal = 0;
for (const point of points) fillTotal += point.fill_m3;
return {
blocks,
residuals: remaining,
transfers,
steps,
spoil_m3: spoil,
borrow_m3: borrow,
natural_spoil_m3: naturalSpoil,
collected_stone_deduction_m3: deductionInput,
collected_stone_deducted_m3: deducted,
structure_spoil_m3: structureSpoilInput,
structure_spoil_added_m3: structureSpoilAdded,
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,
};
}