diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index 4fa13fdb..8d584b5c 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -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 이 줄을 세움. diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index 18df4f9b..ec9f25f2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -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)) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index f58eb6ab..1342b3f0 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -25,7 +25,6 @@ from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( VAT_MODES, cut_gap, overhead_base, - profit_cut, vat_base, ) from B09_Estimation.B09_Estimation_RateOverride import apply_overrides @@ -368,9 +367,9 @@ def calculate_cost(data: CostInput) -> CostResult: ) -#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음 -#: (다섯 건 한 걸음 · 울진 신설 두 걸음). -_CUT_MAX_PASSES = 3 +#: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 — +#: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다. +_CUT_DESCENT_MAX = 3 #: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트). CUT_OFF = ("", "none") @@ -386,30 +385,64 @@ def _calculate_with_scale( ⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어 총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 — 울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`. + + ⚠ **못 밟는 배수가 있다**(2026-09-14 둘째 고침). 한 걸음 낙차가 1원일 때도 2원일 때도 있어 + (부가세가 버림이라 열 걸음에 한 번쯤 2원) 어떤 1,000 배수는 **건너뛴다**. 어림으로 좇으면 + 지나쳐 놓고 못 돌아온다 — 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았다. 그래서 + **목표를 못 박고 가장 작은 보정액을 이분으로 찾는다**(총공사비는 보정액에 대해 비증가). + 그 배수를 못 밟으면 **한 칸 아래 배수**로 목표를 내려 다시 찾는다(뒷받침일 뿐 — 실측 거창 꼴 + 40 자리에서는 한 칸도 안 내려갔다). 이윤은 늘 **가장 적게** 깎인다 — 실무 식(÷1.1 한 걸음)이 + 앉는 자리에서는 같은 값이 나온다(봉화 339 · 영월 632). """ result = _calculate_once(data, dataset, scale, notes) if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0: return result key = "grand_total" if data.cut_basis == "grand_total" else "total_cost" + label = ( + f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" + " 절사 자동보정(산림청고시 2025-82호)" + + (" + 설계자 입력" if data.profit_adjustment_krw else "") + ) + + def attempt(extra: Decimal) -> CostResult: + """이윤을 `extra` 만큼 더 깎아 한 번 셈. `0` 이면 안 자른 결과 그대로.""" + if extra == 0: + return result + return _calculate_once( + replace( + data, + profit_adjustment_krw=data.profit_adjustment_krw + extra, + cut_basis="none", + profit_adjustment_label=label, + ), + dataset, + scale, + notes, + ) + + raw = result.totals[key] + target = raw - cut_gap(raw, data.cut_unit_krw) automatic = _ZERO gap = _ZERO - for _ in range(_CUT_MAX_PASSES): - gap = cut_gap(result.totals[key], data.cut_unit_krw) + landed = result + for _ in range(_CUT_DESCENT_MAX): + # 목표 이하로 내려가는 **가장 작은** 보정액을 이분으로 찾는다. 한 걸음이 적어도 1원을 + # 깎으므로 `raw - target` 이면 반드시 목표 아래로 간다. + low, high = _ZERO, raw - target + while low < high: + middle = (low + high) // 2 + if attempt(middle).totals[key] <= target: + high = middle + else: + low = middle + 1 + landed = attempt(low) + gap = landed.totals[key] - target if gap == 0: + automatic = low break - # 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음). - automatic += profit_cut(gap, data.cut_basis, data.vat_mode) - adjusted = replace( - data, - profit_adjustment_krw=data.profit_adjustment_krw + automatic, - cut_basis="none", - profit_adjustment_label=( - f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" - " 절사 자동보정(산림청고시 2025-82호)" - + (" + 설계자 입력" if data.profit_adjustment_krw else "") - ), - ) - result = _calculate_once(adjusted, dataset, scale, notes) + # 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다. + target -= data.cut_unit_krw + result = landed result.notes.append( f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —" f" 이윤에서 {automatic:,}원 자동보정." @@ -419,7 +452,7 @@ def _calculate_with_scale( if gap: # 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다. result.notes.append( - f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —" + f"⚠ 절사가 {_CUT_DESCENT_MAX}칸 안에 안 앉음 —" f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)" ) return result diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 07842292..5ae0b51b 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -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[] = []; diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py index 22b69816..17f26e40 100644 --- a/resources/tester/test_b09_cost_options.py +++ b/resources/tester/test_b09_cost_options.py @@ -231,3 +231,42 @@ def test_하도급대금_지급보증은_기본_꺼짐() -> None: ) > 0 ) + + +#: 실무 거창 2025 꼴 — 안전관리비 한 줄만 서고 관급이 큰 모양. 총공사비 끝자리가 +#: **건너뛰는** 자리가 생긴다(이윤 1원이 총공사비 1~2원이라 어떤 배수는 못 밟는다). +_SKIPPING = CostInput( + direct_material_krw=Decimal(80_165_010), + direct_labor_krw=Decimal(243_648_150), + direct_expense_krw=Decimal(0), + owner_supplied_material_krw=Decimal(74_634_214), + enabled_items=("safety_management_cost",), + cut_basis="grand_total", + cut_unit_krw=1000, +) + + +@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) +def test_절사는_끝자리를_건너뛰는_자리에서도_앉는다(delta) -> None: + """⭐ 이윤 1원을 깎으면 총공사비는 **1원이나 2원** 줄어 어떤 1,000 배수는 못 밟는다. + + 종전엔 ÷1.1 어림이 한 칸 아래로 지나친 뒤 3걸음 안에 못 돌아와 끝자리 999 가 남았다 + (거창 꼴 40 자리 중 16). 못 밟는 배수면 **한 칸 아래 배수로 내려가** 앉아야 한다. + """ + result = calculate_cost(replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta))) + assert result.totals["grand_total"] % 1000 == 0, delta + assert not [n for n in result.notes if "안 앉음" in n], result.notes + + +@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) +def test_절사는_깎을_수_있는_가장_적은_몫만_깎는다(delta) -> None: + """버림이므로 깎인 몫은 **한 칸(1,000원) 안쪽**이어야 한다. + + ⚠ 이 줄이 깨지면 「못 밟는 배수를 만나 한 칸 내려갔다」는 뜻이다 — 실측(거창 꼴 40 자리)에서는 + 한 칸도 안 내려갔다. 깨지는 자리가 생기면 그 자체가 봐야 할 소식이다. + """ + data = replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta)) + plain = calculate_cost(replace(data, cut_basis="none")) + cut = calculate_cost(data) + drop = plain.totals["grand_total"] - cut.totals["grand_total"] + assert 0 <= drop < 1000, (delta, drop) diff --git a/resources/tester/test_haul_plan_quantity_unpruned.py b/resources/tester/test_haul_plan_quantity_unpruned.py new file mode 100644 index 00000000..5277f45a --- /dev/null +++ b/resources/tester/test_haul_plan_quantity_unpruned.py @@ -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