fix(b06): 유토 배분 수량은 거르지 않은 계획 · 잔진동 거르기는 그림에만(브레인 ①)
- computeHaulPlan 기본 = 거르지 않음, drawing: true 일 때만 진폭 2% 거르기 - 서버 정본 두 벌 — haul_plan(수량·B08) · haul_plan_drawing(토적도) · 화면 선반입은 그림용 - B07 토적도는 그림용 먼저, 옛 저장분은 haul_plan - 936be972 실측: 운반 0 → 245.81㎥(7블록 15띠) · 토취 11,100.86 그대로 · 내역 154,857,626 → 155,207,971 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -83,6 +83,8 @@ if (input.haul_plan_for) {
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
|
||||
conversion: input.context?.earthwork_conversion ?? null,
|
||||
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
|
||||
drawing: true,
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
||||
process.exit(0);
|
||||
@@ -104,18 +106,27 @@ const result = conversion
|
||||
)
|
||||
: null;
|
||||
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
||||
const plan = result
|
||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
conversion: conversion ?? null,
|
||||
})
|
||||
: null;
|
||||
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
|
||||
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
|
||||
const planFor = (drawing: boolean) =>
|
||||
result
|
||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
conversion: conversion ?? null,
|
||||
drawing,
|
||||
})
|
||||
: null;
|
||||
const plan = planFor(false);
|
||||
const drawingPlan = planFor(true);
|
||||
const massHaul = result
|
||||
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
|
||||
? massHaulPayload(result, {
|
||||
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
|
||||
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
|
||||
|
||||
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
|
||||
if curve_entity:
|
||||
entities.append(curve_entity)
|
||||
|
||||
plan = mass_haul.get("haul_plan")
|
||||
# 그림은 잔진동을 거른 계획을 그린다 — 수량 정본(`haul_plan`)은 거르지 않아 balloon 이
|
||||
# 너무 많다(2026-09-14 브레인 ①). 옛 저장분은 그림용이 없어 `haul_plan` 을 그린다.
|
||||
plan = mass_haul.get("haul_plan_drawing") or mass_haul.get("haul_plan")
|
||||
if isinstance(plan, dict):
|
||||
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
|
||||
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
|
||||
|
||||
@@ -220,8 +220,10 @@ export interface HaulPlan {
|
||||
}
|
||||
|
||||
/**
|
||||
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
|
||||
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
||||
* **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면
|
||||
* balloon이 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
||||
* ⚠ 수량 계획에는 걸지 않는다 — 진폭이 커지면 작은 봉우리가 통째로 지워져 운반량이 0 이 됐다
|
||||
* (2026-09-14 936be972 실측 474.14㎥ → 0 · 브레인 ①).
|
||||
*/
|
||||
const MIN_SWING_RATIO = 0.02;
|
||||
|
||||
@@ -603,14 +605,17 @@ export function computeHaulPlan(
|
||||
/** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다.
|
||||
* 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */
|
||||
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 minSwing = Math.max(range * MIN_SWING_RATIO, 1);
|
||||
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
|
||||
const extrema = options?.drawing
|
||||
? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1))
|
||||
: extremaIndices(points);
|
||||
const tiers = sortedLimits(limits);
|
||||
|
||||
const blocks: HaulBlock[] = [];
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""유토 배분 — **수량은 거르지 않은 계획**, 잔진동 거르기는 **그림에만** (2026-09-14 브레인 ①).
|
||||
|
||||
실측(936be972 · ㉳ 샘플 넓힘 뒤): 성토가 늘어 곡선 진폭이 커지자 「진폭 × 2%」 거르기가
|
||||
작은 절토 봉우리를 통째로 지워 **운반량 474.14㎥ → 0** 이 됐다. 거르기는 balloon 이
|
||||
수십 개 깔리는 것을 막으려고 둔 **도면용 손질**이라 수량에 닿으면 안 된다.
|
||||
|
||||
① `computeHaulPlan` 기본은 거르지 않는다 — 작은 봉우리도 블록(운반)으로 남는다.
|
||||
② `drawing: true` 일 때만 거른다 — 그림은 종전대로 깔끔하다.
|
||||
③ 서버 정본은 둘을 따로 둔다 — `haul_plan`(수량) · `haul_plan_drawing`(토적도·화면).
|
||||
④ B07 토적도는 그림용을 먼저 읽는다(없으면 옛 저장분 `haul_plan`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
const out = input.cases.map((options) => {
|
||||
const plan = computeHaulPlan(input.result, null, options);
|
||||
return plan && {
|
||||
hauled_m3: plan.hauled_m3, borrow_m3: plan.borrow_m3, blocks: plan.blocks.length,
|
||||
};
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _bump_then_fill() -> dict:
|
||||
"""앞에 작은 절토 봉우리(+10㎥) · 뒤에 큰 성토(−1,000㎥).
|
||||
|
||||
진폭 2% 는 20㎥ 라 그림용 거르기에서는 봉우리가 지워진다."""
|
||||
nets = [0.0, 10.0, -10.0] + [-100.0] * 10
|
||||
points, cumulative = [], 0.0
|
||||
for index, net in enumerate(nets):
|
||||
cumulative += net
|
||||
points.append(
|
||||
{
|
||||
"station_id": f"S{index:03d}",
|
||||
"chainage_m": index * 20.0,
|
||||
"net_volume_m3": net,
|
||||
"cumulative_volume_m3": cumulative,
|
||||
"cut_soil_m3": max(net, 0.0),
|
||||
"cut_rock_m3": 0.0,
|
||||
"cut_rr_m3": 0.0,
|
||||
"cut_br_m3": 0.0,
|
||||
"cut_compacted_m3": max(net, 0.0),
|
||||
"fill_m3": max(-net, 0.0),
|
||||
"natural_spoil": False,
|
||||
"net_area_m2": net / 20.0,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"points": points,
|
||||
"cut_natural_m3": {"ea": 10.0, "rr": 0.0, "br": 0.0},
|
||||
"cut_compacted_m3": 10.0,
|
||||
"fill_compacted_m3": 1010.0,
|
||||
"final_cumulative_m3": cumulative,
|
||||
"surplus_m3": 0.0,
|
||||
"shortage_m3": -cumulative,
|
||||
"min_cumulative_m3": cumulative,
|
||||
"max_cumulative_m3": 10.0,
|
||||
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
|
||||
}
|
||||
|
||||
|
||||
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.glob("*.js"):
|
||||
text = emitted.read_text(encoding="utf-8")
|
||||
emitted.write_text(
|
||||
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(json.dumps({"result": _bump_then_fill(), "cases": cases}), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_수량_계획은_작은_봉우리도_운반으로_센다(tmp_path: Path) -> None:
|
||||
quantity, drawing = _run(tmp_path, [{}, {"drawing": True}])
|
||||
assert quantity is not None and drawing is not None
|
||||
# ① 기본(수량) — 봉우리 10㎥ 가 뒤 성토로 옮겨진다.
|
||||
assert quantity["hauled_m3"] == pytest.approx(10.0)
|
||||
# ② 그림 — 종전대로 걸러져 블록이 없다.
|
||||
assert drawing["blocks"] == 0
|
||||
assert drawing["hauled_m3"] == pytest.approx(0.0)
|
||||
# 토취(순 부족분)는 거르기와 무관하게 같다 — 운반만 사라졌던 것이다.
|
||||
assert quantity["borrow_m3"] == pytest.approx(drawing["borrow_m3"])
|
||||
|
||||
|
||||
def test_서버_정본은_수량과_그림을_따로_둔다() -> None:
|
||||
source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# ③ 화면 선반입(그림)은 거른 계획, 저장 정본은 둘 다.
|
||||
assert "drawing: true" in source
|
||||
assert "haul_plan_drawing" in source
|
||||
|
||||
|
||||
def test_토적도는_그림용_계획을_먼저_그린다() -> None:
|
||||
def band(volume: float) -> dict:
|
||||
return {
|
||||
"index": 1,
|
||||
"equipment": "free_haul",
|
||||
"volume_m3": volume,
|
||||
"haul_distance_m": 10.0,
|
||||
"ea_m3": volume,
|
||||
"rr_m3": 0.0,
|
||||
"br_m3": 0.0,
|
||||
"level_base_m3": 0.0,
|
||||
"level_apex_m3": volume,
|
||||
"boundary_from_m": 0.0,
|
||||
"boundary_to_m": 40.0,
|
||||
"haul_from_m": 10.0,
|
||||
"haul_to_m": 30.0,
|
||||
}
|
||||
|
||||
def plan(volume: float) -> dict:
|
||||
block = {
|
||||
"index": 1,
|
||||
"from_m": 0.0,
|
||||
"to_m": 40.0,
|
||||
"base_m3": 0.0,
|
||||
"volume_m3": volume,
|
||||
"direction": "forward",
|
||||
"bands": [band(volume)],
|
||||
}
|
||||
return {"blocks": [block], "residuals": [], "transfers": []}
|
||||
|
||||
longitudinal = {"stations": [{"chainage_m": x, "station_id": f"s{x}"} for x in (0, 20, 40)]}
|
||||
points = [
|
||||
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
|
||||
{"station_id": "s20", "chainage_m": 20.0, "cumulative_volume_m3": 700.0},
|
||||
{"station_id": "s40", "chainage_m": 40.0, "cumulative_volume_m3": 0.0},
|
||||
]
|
||||
mass_haul = {"points": points, "haul_plan": plan(111.0), "haul_plan_drawing": plan(222.0)}
|
||||
|
||||
def labels(drawing: dict) -> list[str]:
|
||||
out: list[str] = []
|
||||
|
||||
def walk(entity: dict) -> None:
|
||||
if entity.get("type") == "Text":
|
||||
out.append(entity["shapeData"]["label"])
|
||||
for child in entity.get("children") or []:
|
||||
walk(child)
|
||||
|
||||
for entity in drawing["entities"]:
|
||||
walk(entity)
|
||||
return out
|
||||
|
||||
drawn = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
|
||||
assert "Q= 222.00M3" in drawn and "Q= 111.00M3" not in drawn
|
||||
# 옛 저장분(그림용 없음)은 종전대로 `haul_plan` 을 그린다.
|
||||
del mass_haul["haul_plan_drawing"]
|
||||
legacy = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
|
||||
assert "Q= 111.00M3" in legacy
|
||||
Reference in New Issue
Block a user