Files
Aislo/common_util/common_util_mass_haul_settle.ts
T
eomsangdonandClaude Opus 5 abf586108c fix(B06): 구조물 몫(공제·잔토)이 실제로 흐르게 — 부르는 자리 둘이 인자를 안 넘기고 있었음
통로는 났는데 `_mass_haul_context()` 를 **인자 없이** 불러 값이 늘 `None` 이었다
(`Router_HaulPlan.py:64` · `Server_Calc_Prebuild.py:170`). 오늘 세 번째로 나온
「받는 곳은 있는데 넣는 곳이 없던」 자리다.

- `haul_inputs_for()` 로 B08 의 `project_haul_inputs` 를 받아 두 자리 모두에 넘긴다.
  ⚠ 여기서 다시 세지 않는다 — 두 값 다 B08 전개에서 나온다(CLAUDE.md 5장).
- 측점별 잔토(`structure_spoil_points`)를 받으면 **그 자리 잔량에 얹는다** —
  총량 비례로 흩으면 운반거리가 틀린다. 총량만 오면 종전대로 비례 분배(차선).
- 저장 payload 에 받은 값·실제로 먹은 값 넷을 남긴다 — 「값이 흐르는지」를 저장분에서
  바로 가릴 수 있어야 한다.

실측(route 184): 공제 0(구입이라 채집 없음) · 구조물 잔토 12.875㎥ 가 **실제로 전달됨**.
⚠ 그 노선은 사토가 0(전 구간 토취)이라 더할 잔량이 없어 `added=0` — 아래 물음 참조.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:44:50 +09:00

243 lines
10 KiB
TypeScript

/* =============================================================================
* 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<HaulBand, "ea_m3" | "rr_m3" | "br_m3"> {
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<string, unknown> {
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),
})),
};
}