From 47196780b2d4c54a42801d758c1dad4f47fe20d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 23:04:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(b09):=20=EC=A0=88=EC=82=AC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=B3=B4=EC=A0=95=EC=9D=84=20=EC=9D=B4=EB=B6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=95=89=ED=9E=98=20=E2=80=94=20=EB=AA=BB?= =?UTF-8?q?=20=EB=B0=9F=EB=8A=94=20=EB=81=9D=EC=9E=90=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20999=20=EA=B0=80=20=EB=82=A8=EB=8D=98=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 까닭 — 이윤 1원을 깎으면 총공사비는 1원이나 2원 준다(부가세가 버림). ÷1.1 어림이 한 칸 아래로 지나치고 3걸음 안에 못 돌아와 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았음. 고친 법 — 노릴 배수를 못 박고 목표 이하로 내려가는 **가장 작은 보정액을 이분으로** 찾는다 (총공사비는 보정액에 대해 비증가). 못 밟는 배수면 한 칸 아래로 내려 다시 찾는다(뒷받침 — 실측에서는 한 칸도 안 내려감). _CUT_MAX_PASSES → _CUT_DESCENT_MAX 로 뜻이 바뀜. 판정 — 거창 꼴 40/40 앉음 · 깎인 몫 356~423원으로 늘 한 칸 안쪽 · 실무 6건 자동보정액 그대로(봉화 339 · 영월 632, 골든셋이 지킴). 잴 시험 먼저 빨강 10 자리 확인한 뒤 고침. 전체 시험 2043 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA --- B09_Estimation/B09_Estimation_Engine_Cost.py | 73 ++++++++++++++------ resources/tester/test_b09_cost_options.py | 39 +++++++++++ 2 files changed, 92 insertions(+), 20 deletions(-) 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/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) From 20597071bd208b9cbf144d5b6437860511d611fc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 23:13:48 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(b06):=20=EC=9C=A0=ED=86=A0=20=EB=B0=B0?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EB=9F=89=EC=9D=80=20=EA=B1=B0=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EA=B3=84=ED=9A=8D=20=C2=B7=20?= =?UTF-8?q?=EC=9E=94=EC=A7=84=EB=8F=99=20=EA=B1=B0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=EB=8A=94=20=EA=B7=B8=EB=A6=BC=EC=97=90=EB=A7=8C(=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=E2=91=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B06_Section/B06_Section_Server_Calc_Node.ts | 33 ++- .../B07_DesignDetail_Engine_Cad_MassHaul.py | 4 +- common_util/common_util_mass_haul_balance.ts | 13 +- .../test_haul_plan_quantity_unpruned.py | 199 ++++++++++++++++++ 4 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 resources/tester/test_haul_plan_quantity_unpruned.py 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/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_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 From 8ed4be855b5438b2cce0b25e69519617ece1ceae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 23:13:49 +0900 Subject: [PATCH 3/4] auto: 2026-09-14 23:13 (ESD_LAPTOP) --- .../input.json | 1 + .../js/common_util_mass_haul.js | 366 +++++++++++ .../js/common_util_mass_haul_balance.js | 579 ++++++++++++++++++ .../js/common_util_mass_haul_curve.js | 145 +++++ .../js/common_util_mass_haul_settle.js | 215 +++++++ .../js/common_util_mass_haul_types.js | 12 + .../js/runner.mjs | 11 + .../output.json | 1 + ..._계획은_작은_봉우리도_운반으로_센다current | 1 + 9 files changed, 1331 insertions(+) create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs create mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json create mode 120000 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json new file mode 100644 index 00000000..f346224d --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json @@ -0,0 +1 @@ +{"result": {"points": [{"station_id": "S000", "chainage_m": 0.0, "net_volume_m3": 0.0, "cumulative_volume_m3": 0.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": -0.0, "natural_spoil": false, "net_area_m2": 0.0}, {"station_id": "S001", "chainage_m": 20.0, "net_volume_m3": 10.0, "cumulative_volume_m3": 10.0, "cut_soil_m3": 10.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 10.0, "fill_m3": 0.0, "natural_spoil": false, "net_area_m2": 0.5}, {"station_id": "S002", "chainage_m": 40.0, "net_volume_m3": -10.0, "cumulative_volume_m3": 0.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 10.0, "natural_spoil": false, "net_area_m2": -0.5}, {"station_id": "S003", "chainage_m": 60.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -100.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S004", "chainage_m": 80.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -200.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S005", "chainage_m": 100.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -300.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S006", "chainage_m": 120.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -400.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S007", "chainage_m": 140.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -500.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S008", "chainage_m": 160.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -600.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S009", "chainage_m": 180.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -700.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S010", "chainage_m": 200.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -800.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S011", "chainage_m": 220.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -900.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S012", "chainage_m": 240.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -1000.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}], "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": -1000.0, "surplus_m3": 0.0, "shortage_m3": 1000.0, "min_cumulative_m3": -1000.0, "max_cumulative_m3": 10.0, "conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0}}, "cases": [{}, {"drawing": true}]} \ No newline at end of file diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js new file mode 100644 index 00000000..e0f4a416 --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js @@ -0,0 +1,366 @@ +/* ============================================================================= + * common_util_mass_haul.ts + * 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음. + * + * 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다. + * 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 + * 둔다(렌더러는 `common_util_mass_haul_view`). + * + * ── 계산 기준: 다짐상태 ────────────────────────────────────────────── + * "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 + * 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열) + * 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태 + * 수량은 `cut_natural_m3`로 함께 보존해 B07이 되돌려 쓸 수 있게 한다. + * + * ── 두 기준(종단/횡단)을 함께 내는 이유 ────────────────────────────── + * [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을 + * 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은 + * 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가 + * 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지 + * 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다. + * + * ── 절토를 토사/암반으로 나눠 환산하는 이유 ────────────────────────── + * 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로 + * 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** — + * 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로 + * 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다. + * 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다. + * + * 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시). + * 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만, + * 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.** + * + * 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md + * ========================================================================== */ +const GROUND_TYPES = ["soil", "ripping_rock", "blasting_rock"]; +/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */ +const SERIES_BASES = ["cross", "longitudinal"]; +/** + * 기본으로 켜 두는 곡선 — 횡단 기준만. 종단 기준은 계획고 기반 개략값이라 참고용이므로 + * 사용자가 필요할 때 범례에서 켠다(2026-08-02 사용자 지시). + */ +export const MASS_HAUL_DEFAULT_VISIBLE = ["cross"]; +/** + * 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에 + * 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다. + */ +export const MASS_HAUL_BALANCE_KEY = "balance"; +/** + * 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프 + * 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기). + * 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 — + * 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다. + */ +export function normalizeVisibleBasis(visible) { + const active = SERIES_BASES.filter((basis) => visible.has(basis)); + if (active.length === 1) + return visible; + const next = new Set(visible); + SERIES_BASES.forEach((basis) => next.delete(basis)); + next.add(active[0] ?? SERIES_BASES[0]); + return next; +} +/** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */ +export function applyLegendToggle(visible, key) { + const next = new Set(visible); + if (SERIES_BASES.includes(key)) { + SERIES_BASES.forEach((basis) => next.delete(basis)); + next.add(key); + } + else if (next.has(key)) { + next.delete(key); + } + else { + next.add(key); + } + return normalizeVisibleBasis(next); +} +/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ +function factorFor(conversion, ground) { + const factor = conversion?.[ground]?.compacted; + return Number.isFinite(factor) && factor > 0 ? factor : 1; +} +function emptyVolumes() { + return { soil: 0, ripping_rock: 0, blasting_rock: 0 }; +} +function finiteArea(value) { + return Number.isFinite(value) && value > 0 ? value : 0; +} +function normalizeGround(value) { + return value && GROUND_TYPES.includes(value) ? value : "soil"; +} +/** + * 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점 + * 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다. + */ +function splitCut(design) { + const ground = normalizeGround(design?.ground_type); + const total = finiteArea(design?.cut_area_m2); + if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) { + return ground === "soil" + ? { soil: total, rock: 0, rock_kind: null } + : { soil: 0, rock: total, rock_kind: ground }; + } + return { + soil: finiteArea(design.cut_soil_area_m2), + rock: finiteArea(design.cut_rock_area_m2), + rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground), + }; +} +/** + * 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과 + * 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다. + */ +function sampleNetArea(sample, conversion) { + const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil"); + const rock = sample.rock_kind + ? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind) + : 0; + return soil + rock - sample.fill_area_m2; +} +/** + * 단면적 시퀀스를 누가토량으로 적분한다. + * + * 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽 + * 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면 + * `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다. + */ +function integrate(samples, conversion) { + if (samples.length < 2) + return null; + const points = [ + { + station_id: samples[0].station_id, + chainage_m: samples[0].chainage_m, + net_volume_m3: 0, + cumulative_volume_m3: 0, + cut_soil_m3: 0, + cut_rock_m3: 0, + cut_rr_m3: 0, + cut_br_m3: 0, + cut_compacted_m3: 0, + fill_m3: 0, + natural_spoil: samples[0].natural_spoil, + net_area_m2: sampleNetArea(samples[0], conversion), + }, + ]; + const cutNatural = emptyVolumes(); + let cutCompacted = 0; + let fillCompacted = 0; + let cumulative = 0; + let minCumulative = 0; + let maxCumulative = 0; + for (let index = 1; index < samples.length; index += 1) { + const previous = samples[index - 1]; + const current = samples[index]; + const spanM = current.chainage_m - previous.chainage_m; + // 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다. + if (!(spanM > 0)) + continue; + const halfSpan = spanM / 2; + // 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다. + let segmentCut = 0; + let segmentCutSoil = 0; + let segmentCutRipping = 0; + let segmentCutBlasting = 0; + for (const end of [previous, current]) { + const soilM3 = end.cut_soil_area_m2 * halfSpan; + const rockM3 = end.cut_rock_area_m2 * halfSpan; + if (soilM3 > 0) { + cutNatural.soil += soilM3; + segmentCutSoil += soilM3; + segmentCut += soilM3 * factorFor(conversion, "soil"); + } + if (rockM3 > 0 && end.rock_kind) { + cutNatural[end.rock_kind] += rockM3; + if (end.rock_kind === "blasting_rock") + segmentCutBlasting += rockM3; + else + segmentCutRipping += rockM3; + segmentCut += rockM3 * factorFor(conversion, end.rock_kind); + } + } + // 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다. + const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan; + const net = segmentCut - segmentFill; + cutCompacted += segmentCut; + fillCompacted += segmentFill; + cumulative += net; + minCumulative = Math.min(minCumulative, cumulative); + maxCumulative = Math.max(maxCumulative, cumulative); + points.push({ + station_id: current.station_id, + chainage_m: current.chainage_m, + net_volume_m3: net, + cumulative_volume_m3: cumulative, + cut_soil_m3: segmentCutSoil, + cut_rock_m3: segmentCutRipping + segmentCutBlasting, + cut_rr_m3: segmentCutRipping, + cut_br_m3: segmentCutBlasting, + cut_compacted_m3: segmentCut, + fill_m3: segmentFill, + natural_spoil: previous.natural_spoil && current.natural_spoil, + net_area_m2: sampleNetArea(current, conversion), + }); + } + if (points.length < 2) + return null; + return { + points, + cut_natural_m3: cutNatural, + cut_compacted_m3: cutCompacted, + fill_compacted_m3: fillCompacted, + final_cumulative_m3: cumulative, + surplus_m3: Math.max(cumulative, 0), + shortage_m3: Math.max(-cumulative, 0), + min_cumulative_m3: minCumulative, + max_cumulative_m3: maxCumulative, + conversion, + }; +} +/** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */ +function designedSections(crossSections) { + return crossSections + .filter((section) => section.design && Number.isFinite(section.chainage_m)) + .sort((a, b) => a.chainage_m - b.chainage_m); +} +/** + * 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고 + * 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을 + * 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다. + */ +function naturalSpoilAllowed(design, minSlope) { + if (!Number.isFinite(minSlope)) + return false; + const slope = design?.fill_ground_slope; + return Number.isFinite(slope) && slope >= minSlope; +} +/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */ +function crossAreaSamples(sections, minSlope) { + return sections.map((section) => { + const cut = splitCut(section.design); + return { + station_id: section.station_id, + chainage_m: section.chainage_m, + cut_soil_area_m2: cut.soil, + cut_rock_area_m2: cut.rock, + rock_kind: cut.rock_kind, + fill_area_m2: finiteArea(section.design?.fill_area_m2), + natural_spoil: naturalSpoilAllowed(section.design, minSlope), + }; + }); +} +/** + * 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적. + * + * `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다 + * (`B05_Profile_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다). + * 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다. + * 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다. + * + * 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로 + * 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다). + */ +function longitudinalAreaSamples(longitudinal, sections) { + const profile = longitudinal.design_profiles?.[0]; + if (!profile || !sections.length) + return []; + const samples = profile.samples + .filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m)) + .sort((a, b) => a.chainage_m - b.chainage_m); + if (samples.length < 2) + return []; + // 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다. + let cursor = 0; + return samples.map((sample, index) => { + while (cursor + 1 < sections.length && + Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <= + Math.abs(sections[cursor].chainage_m - sample.chainage_m)) { + cursor += 1; + } + const nearest = sections[cursor]; + const width = finiteArea(nearest.design?.roadbed_width_m); + const height = sample.difference_m; + const cutArea = height < 0 ? -height * width : 0; + const cut = splitCut(nearest.design); + const total = cut.soil + cut.rock; + const rockRatio = total > 0 ? cut.rock / total : 0; + return { + station_id: `${nearest.station_id}@${index}`, + chainage_m: sample.chainage_m, + cut_soil_area_m2: cutArea * (1 - rockRatio), + cut_rock_area_m2: cutArea * rockRatio, + rock_kind: cut.rock_kind, + fill_area_m2: height > 0 ? height * width : 0, + natural_spoil: false, + }; + }); +} +/** + * 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산). + * 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다. + */ +export function computeMassHaul(crossSections, conversion, naturalSpoilMinSlope) { + return integrate(crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope), conversion); +} +/** + * 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다. + * 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다. + */ +export function computeMassHaulSeries(longitudinal, crossSections, conversion, naturalSpoilMinSlope, +/** 낼 기준. 총괄값(배지)만 필요하면 `["cross"]` 로 불러 종단 기준 적분을 건너뛴다. */ +bases = SERIES_BASES) { + const sections = designedSections(crossSections); + const samplesFor = (basis) => basis === "cross" + ? crossAreaSamples(sections, naturalSpoilMinSlope) + : longitudinalAreaSamples(longitudinal, sections); + const series = []; + for (const basis of SERIES_BASES) { + if (!bases.includes(basis)) + continue; + const result = integrate(samplesFor(basis), conversion); + if (result) + series.push({ key: basis, basis, result }); + } + return series; +} +/** + * 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. + * + * **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고 + * 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다. + * 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다. + */ +export function massHaulPayload(result, extra, balloonOffsets) { + const round = (value) => Math.round(value * 100) / 100; + return { + basis: "compacted", + conversion: result.conversion, + ...(extra ?? {}), + // 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다). + ...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}), + cut_natural_m3: { + soil: round(result.cut_natural_m3.soil), + ripping_rock: round(result.cut_natural_m3.ripping_rock), + blasting_rock: round(result.cut_natural_m3.blasting_rock), + }, + cut_compacted_m3: round(result.cut_compacted_m3), + fill_compacted_m3: round(result.fill_compacted_m3), + final_cumulative_m3: round(result.final_cumulative_m3), + surplus_m3: round(result.surplus_m3), + shortage_m3: round(result.shortage_m3), + points: result.points.map((point) => ({ + station_id: point.station_id, + chainage_m: round(point.chainage_m), + net_volume_m3: round(point.net_volume_m3), + cumulative_volume_m3: round(point.cumulative_volume_m3), + // 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위. + cut_soil_m3: round(point.cut_soil_m3), + cut_rock_m3: round(point.cut_rock_m3), + cut_rr_m3: round(point.cut_rr_m3), + cut_br_m3: round(point.cut_br_m3), + net_area_m2: round(point.net_area_m2), + fill_m3: round(point.fill_m3), + })), + }; +} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js new file mode 100644 index 00000000..6dfbc0da --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js @@ -0,0 +1,579 @@ +/* ============================================================================= + * common_util_mass_haul_balance.ts + * 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음. + * + * 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 + * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 → + * 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분. + * + * ── 작도 규칙(참고자료) ───────────────────────────────────────────── + * + * - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계. + * - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반. + * - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량. + * - 운반토량 = 평형선에서 곡선 정점까지의 종거. + * - 평균운반거리 = **종거의 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 { crossFrom, EPSILON, evaluate, extremaIndices, pruneExtrema, segmentOf, } from "./common_util_mass_haul_curve.js"; +import { apportion, cutMix, naturalSpoilRatio, settleResiduals, sortedLimits, } from "./common_util_mass_haul_settle.js"; +export { haulPlanPayload } from "./common_util_mass_haul_settle.js"; +/** + * **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 + * 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; +function blockGeometry(points, fromM, toM, apexM, base, apex) { + const levelAt = (ratio) => base + (apex - base) * ratio; + const chordAt = (ratio) => { + 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, target) { + 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, low, lowLevel, highLevel) { + const floor = Math.min(lowLevel, highLevel); + const ceil = Math.max(lowLevel, highLevel); + const outline = [{ m: low.from, v: lowLevel }]; + const push = (m, v) => { + 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. + */ +/** + * 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구). + * + * 채집석 공제는 사토에서 한 번만 뺀다. + * B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, + * 빼는 자리는 유토곡선의 사토뿐이다 — + * 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. + * + * ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫) + * 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.** + * ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다. + * ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가 + * 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다. + * ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다. + * + * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. + */ +function applyCollectedStoneDeduction(residuals, deduction, byGround, groundUnknownM3, conversion) { + // ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라 + // 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다. + // 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다). + // 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다. + const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion); + const amountToTake = converted === null ? deduction : converted; + if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) + return 0; + const spoils = residuals.filter((residual) => residual.kind === "spoil"); + let left = amountToTake; + const take = (residual, amount) => { + if (amount <= 0) + return; + const before = residual.volume_m3; + const ratio = before > 0 ? (before - amount) / before : 0; + residual.volume_m3 = before - amount; + // 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. + residual.ea_m3 *= ratio; + residual.rr_m3 *= ratio; + residual.br_m3 *= ratio; + left -= amount; + }; + // ① 실어 내는 몫부터 + for (const residual of spoils) { + if (left <= EPSILON) + break; + take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left)); + } + // ② 모자라면 자연방토에서 + for (const residual of spoils) { + if (left <= EPSILON) + break; + const amount = Math.min(residual.natural_m3, left); + residual.natural_m3 -= amount; + take(residual, amount); + } + return amountToTake - Math.max(left, 0); +} +/** + * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). + * + * 구조물 잔토는 사토에 한 번만 더한다. + * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, + * 더하는 자리는 유토곡선의 사토뿐이다. + * + * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리). + * ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로 + * **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고, + * 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다). + * ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다. + * ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다. + * 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다. + * + * 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다. + */ +/** + * 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다). + * 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다. + */ +function groundBucket(ground) { + const text = (ground ?? "").trim(); + if (!text) + return null; + if (text === "토사" || text === "soil" || text === "ea") + return "ea_m3"; + if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") { + return "rr_m3"; + } + if (text === "발파암" || text === "blasting_rock" || text === "br") + return "br_m3"; + return null; +} +/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */ +const GROUND_OF_BUCKET = { + ea_m3: "soil", + rr_m3: "ripping_rock", + br_m3: "blasting_rock", +}; +/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */ +const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"]; +/** + * 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로. + * 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다. + */ +function compactedStoneDeduction(byGround, groundUnknownM3, conversion) { + if (!byGround) + return null; + const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)]).filter(([, value]) => Number.isFinite(value) && value > 0); + const unknown = Number(groundUnknownM3 ?? 0); + const hasUnknown = Number.isFinite(unknown) && unknown > 0; + if (!entries.length && !hasUnknown) + return null; + let total = hasUnknown ? unknown : 0; + for (const [ground, value] of entries) { + const factor = conversion?.[ground]?.compacted; + total += typeof factor === "number" && factor > 0 ? value * factor : value; + } + return total; +} +/** + * **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다. + * + * ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에 + * ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라 + * 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다. + * ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C). + * 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.** + * ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로 + * 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다. + */ +function toCompacted(naturalM3, bucket, conversion) { + if (!bucket || !conversion) + return naturalM3; + const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted; + return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3; +} +function newSpoilResidual(fromM, toM, volumeM3) { + // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(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, ㎥ 양수)을 내기만 하고, + * 더하는 자리는 유토곡선의 사토뿐이다. + * + * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. + * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 + * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 + * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — + * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. + * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. + * ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다. + * ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`, + * 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted` + * 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다. + * 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다). + */ +function applyStructureSpoil(residuals, amount, points, conversion) { + const spoilsOf = () => 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); + // 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라 + // 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다. + const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label); + const target = covering ?? newSpoilResidual(chainage, chainage, 0); + if (!covering) + residuals.push(target); + // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09). + // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다. + const placed = toCompacted(value, bucket, conversion); + target.volume_m3 += placed; + if (bucket) + target[bucket] += placed; + added += placed; + } + 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 (!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 = index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total); + residual.volume_m3 += share; + added += share; + }); + return added; +} +export function computeHaulPlan(result, limits, options) { + 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 = []; + const residuals = []; + const steps = []; + let bandCounter = 0; + const pushBlock = (fromM, toM, base, apexM, apex, direction) => { + const volume = Math.abs(apex - base); + if (!(volume > EPSILON) || !(toM > fromM)) + return; + const geometry = blockGeometry(points, fromM, toM, apexM, base, apex); + // 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다. + const bands = []; + 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, toM, from, to) => { + 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, + }; +} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js new file mode 100644 index 00000000..8ae163fe --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js @@ -0,0 +1,145 @@ +/* ============================================================================= + * common_util_mass_haul_curve.ts + * 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출. + * + * ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ────────── + * 이론상으로는 포물선이다 — 평균단면법 `V = (A₁+A₂)/2 × L`이 "구간 내 단면적이 선형으로 + * 변한다"를 전제하므로, 그 적분인 누가토량은 2차식이 된다. 참고 도면의 종무대 평균운반거리에 + * `20/√2 = 14.14m`가 12번 찍히는 것도 포물선 + "종거 1/2 수평선" 규칙으로만 설명된다 + * (직선이면 `20/2 = 10m`). + * + * 그럼에도 **직선을 쓴다.** 측점을 기준으로 절·성토량을 읽고 판단하는 것이 실무이고, 도면도 + * 직선으로 그린다는 사용자(임도설계 실무자) 판단이다. 대가는 평균운반거리가 포물선 대비 + * 약 40% 짧게 나오는 것이며, 이는 20m·70m 장비 경계 판정에 직접 영향을 준다. + * + * 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다. + * + * 토량 분배 계산(`common_util_mass_haul_balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다. + * ========================================================================== */ +export const EPSILON = 1e-9; +/** + * 누가토량 곡선을 측점 사이에서 포물선으로 볼지 여부. **false = 직선**(사용자 확정). + * 이 값 하나가 곡선 렌더링(`curvePath`)과 수평선 교점(`crossFrom`)을 함께 바꾼다 — + * 둘이 어긋나면 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 이미 겪은 문제). + */ +export const PARABOLIC_MASS_CURVE = false; +export function segmentOf(points, index) { + const a = points[index - 1]; + const b = points[index]; + const span = b.chainage_m - a.chainage_m; + if (!(span > 0)) + return null; + const average = (b.cumulative_volume_m3 - a.cumulative_volume_m3) / span; + const start = Number.isFinite(a.net_area_m2) ? a.net_area_m2 : average; + const end = Number.isFinite(b.net_area_m2) ? b.net_area_m2 : average; + // 포물선이 **구간 끝값을 정확히 통과하도록** 두 단면적을 같은 양만큼 옮긴다. + // `V(Δ) = V₀ + (a₀+a₁)Δ/2`이므로 평균이 어긋나면 끝값이 어긋나고, 그러면 수평선 교점을 + // 못 찾아 블록이 엉뚱하게 길어진다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다). + // 곡률 `(a₁−a₀)`는 그대로라 포물선 모양은 보존된다. + const shift = average - (start + end) / 2; + return { + x0: a.chainage_m, + span, + v0: a.cumulative_volume_m3, + // 직선 모드에서는 구간 평균 단면적 하나로 기울기를 잡는다 → 곡률 0 = 직선. + a0: PARABOLIC_MASS_CURVE ? start + shift : average, + curvature: PARABOLIC_MASS_CURVE ? (end - start) / (2 * span) : 0, + }; +} +export function evaluate(segment, t) { + return segment.v0 + segment.a0 * t + segment.curvature * t * t; +} +/** 구간 2차식이 `[tLo, tHi]` 안에서 `level`을 처음 지나는 t. 없으면 null. */ +function solveSegment(segment, level, tLo, tHi) { + const c = segment.v0 - level; + const roots = []; + if (Math.abs(segment.curvature) < 1e-12) { + if (Math.abs(segment.a0) > EPSILON) + roots.push(-c / segment.a0); + } + else { + const discriminant = segment.a0 * segment.a0 - 4 * segment.curvature * c; + if (discriminant < 0) + return null; + const root = Math.sqrt(discriminant); + roots.push((-segment.a0 - root) / (2 * segment.curvature)); + roots.push((-segment.a0 + root) / (2 * segment.curvature)); + } + const inside = roots + .filter((t) => t >= tLo - 1e-9 && t <= tHi + 1e-9) + .sort((left, right) => left - right); + return inside.length ? Math.min(Math.max(inside[0], tLo), tHi) : null; +} +/** + * `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null. + * 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다. + */ +export function crossFrom(points, fromX, toX, level) { + if (!(toX > fromX)) + return null; + for (let index = 1; index < points.length; index += 1) { + const segment = segmentOf(points, index); + if (!segment) + continue; + const x0 = Math.max(segment.x0, fromX); + const x1 = Math.min(segment.x0 + segment.span, toX); + if (!(x1 > x0)) + continue; + const t = solveSegment(segment, level, x0 - segment.x0, x1 - segment.x0); + if (t !== null) + return segment.x0 + t; + // 구간 끝에서 부호가 바뀌었는데 2차 근을 못 찾으면(수치 한계) 직선으로 떨어뜨린다. + // 교점을 놓치면 블록 경계가 통째로 밀리므로 여기서 반드시 하나는 돌려줘야 한다. + const y0 = evaluate(segment, x0 - segment.x0); + const y1 = evaluate(segment, x1 - segment.x0); + if ((y0 - level) * (y1 - level) <= 0) { + if (Math.abs(y1 - y0) < EPSILON) + return x0; + return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0); + } + } + return null; +} +/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */ +export function extremaIndices(points) { + const list = [0]; + let sign = 0; + for (let index = 1; index < points.length; index += 1) { + const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3; + if (Math.abs(delta) < EPSILON) + continue; + const next = delta > 0 ? 1 : -1; + if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) + list.push(index - 1); + sign = next; + } + if (list[list.length - 1] !== points.length - 1) + list.push(points.length - 1); + return list; +} +/** + * 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 — + * 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다. + */ +export function pruneExtrema(points, indices, minSwing) { + const valueOf = (index) => points[index].cumulative_volume_m3; + let list = indices.slice(); + let changed = true; + while (changed && list.length > 2) { + changed = false; + for (let i = 1; i < list.length - 1; i += 1) { + const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1])); + const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1])); + if (Math.min(left, right) >= minSwing) + continue; + const partner = left <= right ? i - 1 : i + 1; + const drop = new Set([i]); + if (partner > 0 && partner < list.length - 1) + drop.add(partner); + list = list.filter((_, position) => !drop.has(position)); + changed = true; + break; + } + } + return list; +} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js new file mode 100644 index 00000000..530e002e --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js @@ -0,0 +1,215 @@ +/* ============================================================================= + * common_util_mass_haul_settle.ts + * 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화. + * + * `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와 + * 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다. + * + * 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만). + * 타입 import는 실행 시 사라지므로 순환이 생기지 않는다. + * ========================================================================== */ +import { EPSILON } from "./common_util_mass_haul_curve.js"; +/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ +function factorFor(conversion, ground) { + const factor = conversion?.[ground]?.compacted; + return Number.isFinite(factor) && factor > 0 ? factor : 1; +} +/** + * `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만 + * 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다. + */ +export function cutMix(points, fromX, toX, conversion) { + const mix = { 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, fromX, toX) { + 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, volume) { + 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) { + 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, distanceM) { + 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, residuals, conversion, limits) { + const center = (residual) => (residual.from_m + residual.to_m) / 2; + const level = (residual) => (residual.level_from_m3 + residual.level_to_m3) / 2; + const transfers = []; + const pairs = []; + 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) { + const round = (value) => 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), + // ⚠ **지반을 모르는 몫**(㎥) — 갈래 합과 총량의 차이다. 지금은 **구조물 잔토**가 + // 그것이다(어느 지반에서 파낸 흙인지 우리가 판정하지 않는다). 받는 쪽이 덤프 단가를 + // 토사/암으로 가를 때 **이 몫만 근거가 없다**는 것을 알아야 하므로 값으로 낸다 + // (2026-09-09 — 안 내면 0 으로 눅여 토사로 세기 쉽다). + ground_unknown_m3: round(Math.max(residual.volume_m3 - (residual.ea_m3 + residual.rr_m3 + residual.br_m3), 0)), + })), + }; +} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js new file mode 100644 index 00000000..53453fc5 --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js @@ -0,0 +1,12 @@ +/* ============================================================================= + * common_util_mass_haul_types.ts + * 유토곡선 엔진이 읽는 최소 입력 타입 — B05(계획 기준)와 B06(정식 기준)이 함께 쓴다. + * + * 엔진은 횡단 설계 결과에서 **자기가 실제로 읽는 필드만** 알면 된다. 페이지별 API 응답 + * 타입(`B06_..._Api_Fetch.CrossSection` 등)을 그대로 요구하면 B05가 B06 모듈을 끌어와야 + * 하므로, 구조적 부분집합만 여기 두고 각 페이지 타입이 그대로 대입되게 한다. + * + * 지반유형·토량환산계수는 정의처가 `config_system.py` 한 곳이므로 여기서도 한 번만 적고, + * 페이지 API 타입 모듈이 이 정의를 재수출한다(사본 금지). + * ========================================================================== */ +export {}; diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs new file mode 100644 index 00000000..3ae2a5cb --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs @@ -0,0 +1,11 @@ + +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)); diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json new file mode 100644 index 00000000..56efec12 --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json @@ -0,0 +1 @@ +[{"hauled_m3":10,"borrow_m3":1000,"blocks":1},{"hauled_m3":0,"borrow_m3":1000,"blocks":0}] \ No newline at end of file diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current new file mode 120000 index 00000000..0ef2dcea --- /dev/null +++ b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current @@ -0,0 +1 @@ +C:/Aislo_wt/aislo-b0506/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0 \ No newline at end of file From 6ea73956e5414a9551d3fede1f10f6144a6dd82d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 23:14:42 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore(tester):=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EC=98=AC=EB=9D=BC=EA=B0=84=20pytest=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EA=B1=B7=EC=96=B4=EB=83=84(auto=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=208ed4be85=20=EC=97=90=20=EC=84=9E=EC=9E=84?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=ED=97=98=20=EC=82=B0=EC=B6=9C=EB=AC=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../input.json | 1 - .../js/common_util_mass_haul.js | 366 ----------- .../js/common_util_mass_haul_balance.js | 579 ------------------ .../js/common_util_mass_haul_curve.js | 145 ----- .../js/common_util_mass_haul_settle.js | 215 ------- .../js/common_util_mass_haul_types.js | 12 - .../js/runner.mjs | 11 - .../output.json | 1 - ..._계획은_작은_봉우리도_운반으로_센다current | 1 - 9 files changed, 1331 deletions(-) delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs delete mode 100644 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json delete mode 120000 UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json deleted file mode 100644 index f346224d..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json +++ /dev/null @@ -1 +0,0 @@ -{"result": {"points": [{"station_id": "S000", "chainage_m": 0.0, "net_volume_m3": 0.0, "cumulative_volume_m3": 0.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": -0.0, "natural_spoil": false, "net_area_m2": 0.0}, {"station_id": "S001", "chainage_m": 20.0, "net_volume_m3": 10.0, "cumulative_volume_m3": 10.0, "cut_soil_m3": 10.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 10.0, "fill_m3": 0.0, "natural_spoil": false, "net_area_m2": 0.5}, {"station_id": "S002", "chainage_m": 40.0, "net_volume_m3": -10.0, "cumulative_volume_m3": 0.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 10.0, "natural_spoil": false, "net_area_m2": -0.5}, {"station_id": "S003", "chainage_m": 60.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -100.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S004", "chainage_m": 80.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -200.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S005", "chainage_m": 100.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -300.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S006", "chainage_m": 120.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -400.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S007", "chainage_m": 140.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -500.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S008", "chainage_m": 160.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -600.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S009", "chainage_m": 180.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -700.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S010", "chainage_m": 200.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -800.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S011", "chainage_m": 220.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -900.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S012", "chainage_m": 240.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -1000.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}], "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": -1000.0, "surplus_m3": 0.0, "shortage_m3": 1000.0, "min_cumulative_m3": -1000.0, "max_cumulative_m3": 10.0, "conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0}}, "cases": [{}, {"drawing": true}]} \ No newline at end of file diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js deleted file mode 100644 index e0f4a416..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js +++ /dev/null @@ -1,366 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul.ts - * 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음. - * - * 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다. - * 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 - * 둔다(렌더러는 `common_util_mass_haul_view`). - * - * ── 계산 기준: 다짐상태 ────────────────────────────────────────────── - * "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 - * 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열) - * 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태 - * 수량은 `cut_natural_m3`로 함께 보존해 B07이 되돌려 쓸 수 있게 한다. - * - * ── 두 기준(종단/횡단)을 함께 내는 이유 ────────────────────────────── - * [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을 - * 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은 - * 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가 - * 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지 - * 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다. - * - * ── 절토를 토사/암반으로 나눠 환산하는 이유 ────────────────────────── - * 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로 - * 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** — - * 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로 - * 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다. - * 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다. - * - * 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시). - * 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만, - * 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.** - * - * 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md - * ========================================================================== */ -const GROUND_TYPES = ["soil", "ripping_rock", "blasting_rock"]; -/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */ -const SERIES_BASES = ["cross", "longitudinal"]; -/** - * 기본으로 켜 두는 곡선 — 횡단 기준만. 종단 기준은 계획고 기반 개략값이라 참고용이므로 - * 사용자가 필요할 때 범례에서 켠다(2026-08-02 사용자 지시). - */ -export const MASS_HAUL_DEFAULT_VISIBLE = ["cross"]; -/** - * 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에 - * 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다. - */ -export const MASS_HAUL_BALANCE_KEY = "balance"; -/** - * 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프 - * 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기). - * 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 — - * 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다. - */ -export function normalizeVisibleBasis(visible) { - const active = SERIES_BASES.filter((basis) => visible.has(basis)); - if (active.length === 1) - return visible; - const next = new Set(visible); - SERIES_BASES.forEach((basis) => next.delete(basis)); - next.add(active[0] ?? SERIES_BASES[0]); - return next; -} -/** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */ -export function applyLegendToggle(visible, key) { - const next = new Set(visible); - if (SERIES_BASES.includes(key)) { - SERIES_BASES.forEach((basis) => next.delete(basis)); - next.add(key); - } - else if (next.has(key)) { - next.delete(key); - } - else { - next.add(key); - } - return normalizeVisibleBasis(next); -} -/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ -function factorFor(conversion, ground) { - const factor = conversion?.[ground]?.compacted; - return Number.isFinite(factor) && factor > 0 ? factor : 1; -} -function emptyVolumes() { - return { soil: 0, ripping_rock: 0, blasting_rock: 0 }; -} -function finiteArea(value) { - return Number.isFinite(value) && value > 0 ? value : 0; -} -function normalizeGround(value) { - return value && GROUND_TYPES.includes(value) ? value : "soil"; -} -/** - * 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점 - * 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다. - */ -function splitCut(design) { - const ground = normalizeGround(design?.ground_type); - const total = finiteArea(design?.cut_area_m2); - if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) { - return ground === "soil" - ? { soil: total, rock: 0, rock_kind: null } - : { soil: 0, rock: total, rock_kind: ground }; - } - return { - soil: finiteArea(design.cut_soil_area_m2), - rock: finiteArea(design.cut_rock_area_m2), - rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground), - }; -} -/** - * 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과 - * 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다. - */ -function sampleNetArea(sample, conversion) { - const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil"); - const rock = sample.rock_kind - ? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind) - : 0; - return soil + rock - sample.fill_area_m2; -} -/** - * 단면적 시퀀스를 누가토량으로 적분한다. - * - * 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽 - * 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면 - * `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다. - */ -function integrate(samples, conversion) { - if (samples.length < 2) - return null; - const points = [ - { - station_id: samples[0].station_id, - chainage_m: samples[0].chainage_m, - net_volume_m3: 0, - cumulative_volume_m3: 0, - cut_soil_m3: 0, - cut_rock_m3: 0, - cut_rr_m3: 0, - cut_br_m3: 0, - cut_compacted_m3: 0, - fill_m3: 0, - natural_spoil: samples[0].natural_spoil, - net_area_m2: sampleNetArea(samples[0], conversion), - }, - ]; - const cutNatural = emptyVolumes(); - let cutCompacted = 0; - let fillCompacted = 0; - let cumulative = 0; - let minCumulative = 0; - let maxCumulative = 0; - for (let index = 1; index < samples.length; index += 1) { - const previous = samples[index - 1]; - const current = samples[index]; - const spanM = current.chainage_m - previous.chainage_m; - // 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다. - if (!(spanM > 0)) - continue; - const halfSpan = spanM / 2; - // 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다. - let segmentCut = 0; - let segmentCutSoil = 0; - let segmentCutRipping = 0; - let segmentCutBlasting = 0; - for (const end of [previous, current]) { - const soilM3 = end.cut_soil_area_m2 * halfSpan; - const rockM3 = end.cut_rock_area_m2 * halfSpan; - if (soilM3 > 0) { - cutNatural.soil += soilM3; - segmentCutSoil += soilM3; - segmentCut += soilM3 * factorFor(conversion, "soil"); - } - if (rockM3 > 0 && end.rock_kind) { - cutNatural[end.rock_kind] += rockM3; - if (end.rock_kind === "blasting_rock") - segmentCutBlasting += rockM3; - else - segmentCutRipping += rockM3; - segmentCut += rockM3 * factorFor(conversion, end.rock_kind); - } - } - // 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다. - const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan; - const net = segmentCut - segmentFill; - cutCompacted += segmentCut; - fillCompacted += segmentFill; - cumulative += net; - minCumulative = Math.min(minCumulative, cumulative); - maxCumulative = Math.max(maxCumulative, cumulative); - points.push({ - station_id: current.station_id, - chainage_m: current.chainage_m, - net_volume_m3: net, - cumulative_volume_m3: cumulative, - cut_soil_m3: segmentCutSoil, - cut_rock_m3: segmentCutRipping + segmentCutBlasting, - cut_rr_m3: segmentCutRipping, - cut_br_m3: segmentCutBlasting, - cut_compacted_m3: segmentCut, - fill_m3: segmentFill, - natural_spoil: previous.natural_spoil && current.natural_spoil, - net_area_m2: sampleNetArea(current, conversion), - }); - } - if (points.length < 2) - return null; - return { - points, - cut_natural_m3: cutNatural, - cut_compacted_m3: cutCompacted, - fill_compacted_m3: fillCompacted, - final_cumulative_m3: cumulative, - surplus_m3: Math.max(cumulative, 0), - shortage_m3: Math.max(-cumulative, 0), - min_cumulative_m3: minCumulative, - max_cumulative_m3: maxCumulative, - conversion, - }; -} -/** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */ -function designedSections(crossSections) { - return crossSections - .filter((section) => section.design && Number.isFinite(section.chainage_m)) - .sort((a, b) => a.chainage_m - b.chainage_m); -} -/** - * 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고 - * 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을 - * 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다. - */ -function naturalSpoilAllowed(design, minSlope) { - if (!Number.isFinite(minSlope)) - return false; - const slope = design?.fill_ground_slope; - return Number.isFinite(slope) && slope >= minSlope; -} -/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */ -function crossAreaSamples(sections, minSlope) { - return sections.map((section) => { - const cut = splitCut(section.design); - return { - station_id: section.station_id, - chainage_m: section.chainage_m, - cut_soil_area_m2: cut.soil, - cut_rock_area_m2: cut.rock, - rock_kind: cut.rock_kind, - fill_area_m2: finiteArea(section.design?.fill_area_m2), - natural_spoil: naturalSpoilAllowed(section.design, minSlope), - }; - }); -} -/** - * 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적. - * - * `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다 - * (`B05_Profile_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다). - * 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다. - * 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다. - * - * 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로 - * 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다). - */ -function longitudinalAreaSamples(longitudinal, sections) { - const profile = longitudinal.design_profiles?.[0]; - if (!profile || !sections.length) - return []; - const samples = profile.samples - .filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m)) - .sort((a, b) => a.chainage_m - b.chainage_m); - if (samples.length < 2) - return []; - // 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다. - let cursor = 0; - return samples.map((sample, index) => { - while (cursor + 1 < sections.length && - Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <= - Math.abs(sections[cursor].chainage_m - sample.chainage_m)) { - cursor += 1; - } - const nearest = sections[cursor]; - const width = finiteArea(nearest.design?.roadbed_width_m); - const height = sample.difference_m; - const cutArea = height < 0 ? -height * width : 0; - const cut = splitCut(nearest.design); - const total = cut.soil + cut.rock; - const rockRatio = total > 0 ? cut.rock / total : 0; - return { - station_id: `${nearest.station_id}@${index}`, - chainage_m: sample.chainage_m, - cut_soil_area_m2: cutArea * (1 - rockRatio), - cut_rock_area_m2: cutArea * rockRatio, - rock_kind: cut.rock_kind, - fill_area_m2: height > 0 ? height * width : 0, - natural_spoil: false, - }; - }); -} -/** - * 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산). - * 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다. - */ -export function computeMassHaul(crossSections, conversion, naturalSpoilMinSlope) { - return integrate(crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope), conversion); -} -/** - * 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다. - * 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다. - */ -export function computeMassHaulSeries(longitudinal, crossSections, conversion, naturalSpoilMinSlope, -/** 낼 기준. 총괄값(배지)만 필요하면 `["cross"]` 로 불러 종단 기준 적분을 건너뛴다. */ -bases = SERIES_BASES) { - const sections = designedSections(crossSections); - const samplesFor = (basis) => basis === "cross" - ? crossAreaSamples(sections, naturalSpoilMinSlope) - : longitudinalAreaSamples(longitudinal, sections); - const series = []; - for (const basis of SERIES_BASES) { - if (!bases.includes(basis)) - continue; - const result = integrate(samplesFor(basis), conversion); - if (result) - series.push({ key: basis, basis, result }); - } - return series; -} -/** - * 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. - * - * **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고 - * 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다. - * 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다. - */ -export function massHaulPayload(result, extra, balloonOffsets) { - const round = (value) => Math.round(value * 100) / 100; - return { - basis: "compacted", - conversion: result.conversion, - ...(extra ?? {}), - // 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다). - ...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}), - cut_natural_m3: { - soil: round(result.cut_natural_m3.soil), - ripping_rock: round(result.cut_natural_m3.ripping_rock), - blasting_rock: round(result.cut_natural_m3.blasting_rock), - }, - cut_compacted_m3: round(result.cut_compacted_m3), - fill_compacted_m3: round(result.fill_compacted_m3), - final_cumulative_m3: round(result.final_cumulative_m3), - surplus_m3: round(result.surplus_m3), - shortage_m3: round(result.shortage_m3), - points: result.points.map((point) => ({ - station_id: point.station_id, - chainage_m: round(point.chainage_m), - net_volume_m3: round(point.net_volume_m3), - cumulative_volume_m3: round(point.cumulative_volume_m3), - // 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위. - cut_soil_m3: round(point.cut_soil_m3), - cut_rock_m3: round(point.cut_rock_m3), - cut_rr_m3: round(point.cut_rr_m3), - cut_br_m3: round(point.cut_br_m3), - net_area_m2: round(point.net_area_m2), - fill_m3: round(point.fill_m3), - })), - }; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js deleted file mode 100644 index 6dfbc0da..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js +++ /dev/null @@ -1,579 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_balance.ts - * 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음. - * - * 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 - * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 → - * 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분. - * - * ── 작도 규칙(참고자료) ───────────────────────────────────────────── - * - * - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계. - * - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반. - * - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량. - * - 운반토량 = 평형선에서 곡선 정점까지의 종거. - * - 평균운반거리 = **종거의 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 { crossFrom, EPSILON, evaluate, extremaIndices, pruneExtrema, segmentOf, } from "./common_util_mass_haul_curve.js"; -import { apportion, cutMix, naturalSpoilRatio, settleResiduals, sortedLimits, } from "./common_util_mass_haul_settle.js"; -export { haulPlanPayload } from "./common_util_mass_haul_settle.js"; -/** - * **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 - * 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; -function blockGeometry(points, fromM, toM, apexM, base, apex) { - const levelAt = (ratio) => base + (apex - base) * ratio; - const chordAt = (ratio) => { - 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, target) { - 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, low, lowLevel, highLevel) { - const floor = Math.min(lowLevel, highLevel); - const ceil = Math.max(lowLevel, highLevel); - const outline = [{ m: low.from, v: lowLevel }]; - const push = (m, v) => { - 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. - */ -/** - * 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구). - * - * 채집석 공제는 사토에서 한 번만 뺀다. - * B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, - * 빼는 자리는 유토곡선의 사토뿐이다 — - * 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. - * - * ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫) - * 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.** - * ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다. - * ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가 - * 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다. - * ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다. - * - * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. - */ -function applyCollectedStoneDeduction(residuals, deduction, byGround, groundUnknownM3, conversion) { - // ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라 - // 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다. - // 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다). - // 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다. - const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion); - const amountToTake = converted === null ? deduction : converted; - if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) - return 0; - const spoils = residuals.filter((residual) => residual.kind === "spoil"); - let left = amountToTake; - const take = (residual, amount) => { - if (amount <= 0) - return; - const before = residual.volume_m3; - const ratio = before > 0 ? (before - amount) / before : 0; - residual.volume_m3 = before - amount; - // 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. - residual.ea_m3 *= ratio; - residual.rr_m3 *= ratio; - residual.br_m3 *= ratio; - left -= amount; - }; - // ① 실어 내는 몫부터 - for (const residual of spoils) { - if (left <= EPSILON) - break; - take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left)); - } - // ② 모자라면 자연방토에서 - for (const residual of spoils) { - if (left <= EPSILON) - break; - const amount = Math.min(residual.natural_m3, left); - residual.natural_m3 -= amount; - take(residual, amount); - } - return amountToTake - Math.max(left, 0); -} -/** - * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). - * - * 구조물 잔토는 사토에 한 번만 더한다. - * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, - * 더하는 자리는 유토곡선의 사토뿐이다. - * - * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리). - * ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로 - * **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고, - * 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다). - * ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다. - * ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다. - * 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다. - * - * 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다. - */ -/** - * 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다). - * 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다. - */ -function groundBucket(ground) { - const text = (ground ?? "").trim(); - if (!text) - return null; - if (text === "토사" || text === "soil" || text === "ea") - return "ea_m3"; - if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") { - return "rr_m3"; - } - if (text === "발파암" || text === "blasting_rock" || text === "br") - return "br_m3"; - return null; -} -/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */ -const GROUND_OF_BUCKET = { - ea_m3: "soil", - rr_m3: "ripping_rock", - br_m3: "blasting_rock", -}; -/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */ -const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"]; -/** - * 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로. - * 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다. - */ -function compactedStoneDeduction(byGround, groundUnknownM3, conversion) { - if (!byGround) - return null; - const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)]).filter(([, value]) => Number.isFinite(value) && value > 0); - const unknown = Number(groundUnknownM3 ?? 0); - const hasUnknown = Number.isFinite(unknown) && unknown > 0; - if (!entries.length && !hasUnknown) - return null; - let total = hasUnknown ? unknown : 0; - for (const [ground, value] of entries) { - const factor = conversion?.[ground]?.compacted; - total += typeof factor === "number" && factor > 0 ? value * factor : value; - } - return total; -} -/** - * **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다. - * - * ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에 - * ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라 - * 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다. - * ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C). - * 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.** - * ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로 - * 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다. - */ -function toCompacted(naturalM3, bucket, conversion) { - if (!bucket || !conversion) - return naturalM3; - const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted; - return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3; -} -function newSpoilResidual(fromM, toM, volumeM3) { - // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(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, ㎥ 양수)을 내기만 하고, - * 더하는 자리는 유토곡선의 사토뿐이다. - * - * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. - * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 - * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 - * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — - * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. - * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. - * ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다. - * ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`, - * 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted` - * 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다. - * 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다). - */ -function applyStructureSpoil(residuals, amount, points, conversion) { - const spoilsOf = () => 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); - // 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라 - // 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다. - const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label); - const target = covering ?? newSpoilResidual(chainage, chainage, 0); - if (!covering) - residuals.push(target); - // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09). - // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다. - const placed = toCompacted(value, bucket, conversion); - target.volume_m3 += placed; - if (bucket) - target[bucket] += placed; - added += placed; - } - 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 (!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 = index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total); - residual.volume_m3 += share; - added += share; - }); - return added; -} -export function computeHaulPlan(result, limits, options) { - 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 = []; - const residuals = []; - const steps = []; - let bandCounter = 0; - const pushBlock = (fromM, toM, base, apexM, apex, direction) => { - const volume = Math.abs(apex - base); - if (!(volume > EPSILON) || !(toM > fromM)) - return; - const geometry = blockGeometry(points, fromM, toM, apexM, base, apex); - // 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다. - const bands = []; - 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, toM, from, to) => { - 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, - }; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js deleted file mode 100644 index 8ae163fe..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js +++ /dev/null @@ -1,145 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_curve.ts - * 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출. - * - * ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ────────── - * 이론상으로는 포물선이다 — 평균단면법 `V = (A₁+A₂)/2 × L`이 "구간 내 단면적이 선형으로 - * 변한다"를 전제하므로, 그 적분인 누가토량은 2차식이 된다. 참고 도면의 종무대 평균운반거리에 - * `20/√2 = 14.14m`가 12번 찍히는 것도 포물선 + "종거 1/2 수평선" 규칙으로만 설명된다 - * (직선이면 `20/2 = 10m`). - * - * 그럼에도 **직선을 쓴다.** 측점을 기준으로 절·성토량을 읽고 판단하는 것이 실무이고, 도면도 - * 직선으로 그린다는 사용자(임도설계 실무자) 판단이다. 대가는 평균운반거리가 포물선 대비 - * 약 40% 짧게 나오는 것이며, 이는 20m·70m 장비 경계 판정에 직접 영향을 준다. - * - * 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다. - * - * 토량 분배 계산(`common_util_mass_haul_balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다. - * ========================================================================== */ -export const EPSILON = 1e-9; -/** - * 누가토량 곡선을 측점 사이에서 포물선으로 볼지 여부. **false = 직선**(사용자 확정). - * 이 값 하나가 곡선 렌더링(`curvePath`)과 수평선 교점(`crossFrom`)을 함께 바꾼다 — - * 둘이 어긋나면 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 이미 겪은 문제). - */ -export const PARABOLIC_MASS_CURVE = false; -export function segmentOf(points, index) { - const a = points[index - 1]; - const b = points[index]; - const span = b.chainage_m - a.chainage_m; - if (!(span > 0)) - return null; - const average = (b.cumulative_volume_m3 - a.cumulative_volume_m3) / span; - const start = Number.isFinite(a.net_area_m2) ? a.net_area_m2 : average; - const end = Number.isFinite(b.net_area_m2) ? b.net_area_m2 : average; - // 포물선이 **구간 끝값을 정확히 통과하도록** 두 단면적을 같은 양만큼 옮긴다. - // `V(Δ) = V₀ + (a₀+a₁)Δ/2`이므로 평균이 어긋나면 끝값이 어긋나고, 그러면 수평선 교점을 - // 못 찾아 블록이 엉뚱하게 길어진다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다). - // 곡률 `(a₁−a₀)`는 그대로라 포물선 모양은 보존된다. - const shift = average - (start + end) / 2; - return { - x0: a.chainage_m, - span, - v0: a.cumulative_volume_m3, - // 직선 모드에서는 구간 평균 단면적 하나로 기울기를 잡는다 → 곡률 0 = 직선. - a0: PARABOLIC_MASS_CURVE ? start + shift : average, - curvature: PARABOLIC_MASS_CURVE ? (end - start) / (2 * span) : 0, - }; -} -export function evaluate(segment, t) { - return segment.v0 + segment.a0 * t + segment.curvature * t * t; -} -/** 구간 2차식이 `[tLo, tHi]` 안에서 `level`을 처음 지나는 t. 없으면 null. */ -function solveSegment(segment, level, tLo, tHi) { - const c = segment.v0 - level; - const roots = []; - if (Math.abs(segment.curvature) < 1e-12) { - if (Math.abs(segment.a0) > EPSILON) - roots.push(-c / segment.a0); - } - else { - const discriminant = segment.a0 * segment.a0 - 4 * segment.curvature * c; - if (discriminant < 0) - return null; - const root = Math.sqrt(discriminant); - roots.push((-segment.a0 - root) / (2 * segment.curvature)); - roots.push((-segment.a0 + root) / (2 * segment.curvature)); - } - const inside = roots - .filter((t) => t >= tLo - 1e-9 && t <= tHi + 1e-9) - .sort((left, right) => left - right); - return inside.length ? Math.min(Math.max(inside[0], tLo), tHi) : null; -} -/** - * `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null. - * 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다. - */ -export function crossFrom(points, fromX, toX, level) { - if (!(toX > fromX)) - return null; - for (let index = 1; index < points.length; index += 1) { - const segment = segmentOf(points, index); - if (!segment) - continue; - const x0 = Math.max(segment.x0, fromX); - const x1 = Math.min(segment.x0 + segment.span, toX); - if (!(x1 > x0)) - continue; - const t = solveSegment(segment, level, x0 - segment.x0, x1 - segment.x0); - if (t !== null) - return segment.x0 + t; - // 구간 끝에서 부호가 바뀌었는데 2차 근을 못 찾으면(수치 한계) 직선으로 떨어뜨린다. - // 교점을 놓치면 블록 경계가 통째로 밀리므로 여기서 반드시 하나는 돌려줘야 한다. - const y0 = evaluate(segment, x0 - segment.x0); - const y1 = evaluate(segment, x1 - segment.x0); - if ((y0 - level) * (y1 - level) <= 0) { - if (Math.abs(y1 - y0) < EPSILON) - return x0; - return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0); - } - } - return null; -} -/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */ -export function extremaIndices(points) { - const list = [0]; - let sign = 0; - for (let index = 1; index < points.length; index += 1) { - const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3; - if (Math.abs(delta) < EPSILON) - continue; - const next = delta > 0 ? 1 : -1; - if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) - list.push(index - 1); - sign = next; - } - if (list[list.length - 1] !== points.length - 1) - list.push(points.length - 1); - return list; -} -/** - * 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 — - * 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다. - */ -export function pruneExtrema(points, indices, minSwing) { - const valueOf = (index) => points[index].cumulative_volume_m3; - let list = indices.slice(); - let changed = true; - while (changed && list.length > 2) { - changed = false; - for (let i = 1; i < list.length - 1; i += 1) { - const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1])); - const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1])); - if (Math.min(left, right) >= minSwing) - continue; - const partner = left <= right ? i - 1 : i + 1; - const drop = new Set([i]); - if (partner > 0 && partner < list.length - 1) - drop.add(partner); - list = list.filter((_, position) => !drop.has(position)); - changed = true; - break; - } - } - return list; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js deleted file mode 100644 index 530e002e..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js +++ /dev/null @@ -1,215 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_settle.ts - * 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화. - * - * `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와 - * 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다. - * - * 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만). - * 타입 import는 실행 시 사라지므로 순환이 생기지 않는다. - * ========================================================================== */ -import { EPSILON } from "./common_util_mass_haul_curve.js"; -/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ -function factorFor(conversion, ground) { - const factor = conversion?.[ground]?.compacted; - return Number.isFinite(factor) && factor > 0 ? factor : 1; -} -/** - * `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만 - * 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다. - */ -export function cutMix(points, fromX, toX, conversion) { - const mix = { 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, fromX, toX) { - 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, volume) { - 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) { - 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, distanceM) { - 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, residuals, conversion, limits) { - const center = (residual) => (residual.from_m + residual.to_m) / 2; - const level = (residual) => (residual.level_from_m3 + residual.level_to_m3) / 2; - const transfers = []; - const pairs = []; - 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) { - const round = (value) => 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), - // ⚠ **지반을 모르는 몫**(㎥) — 갈래 합과 총량의 차이다. 지금은 **구조물 잔토**가 - // 그것이다(어느 지반에서 파낸 흙인지 우리가 판정하지 않는다). 받는 쪽이 덤프 단가를 - // 토사/암으로 가를 때 **이 몫만 근거가 없다**는 것을 알아야 하므로 값으로 낸다 - // (2026-09-09 — 안 내면 0 으로 눅여 토사로 세기 쉽다). - ground_unknown_m3: round(Math.max(residual.volume_m3 - (residual.ea_m3 + residual.rr_m3 + residual.br_m3), 0)), - })), - }; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js deleted file mode 100644 index 53453fc5..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js +++ /dev/null @@ -1,12 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_types.ts - * 유토곡선 엔진이 읽는 최소 입력 타입 — B05(계획 기준)와 B06(정식 기준)이 함께 쓴다. - * - * 엔진은 횡단 설계 결과에서 **자기가 실제로 읽는 필드만** 알면 된다. 페이지별 API 응답 - * 타입(`B06_..._Api_Fetch.CrossSection` 등)을 그대로 요구하면 B05가 B06 모듈을 끌어와야 - * 하므로, 구조적 부분집합만 여기 두고 각 페이지 타입이 그대로 대입되게 한다. - * - * 지반유형·토량환산계수는 정의처가 `config_system.py` 한 곳이므로 여기서도 한 번만 적고, - * 페이지 API 타입 모듈이 이 정의를 재수출한다(사본 금지). - * ========================================================================== */ -export {}; diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs deleted file mode 100644 index 3ae2a5cb..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs +++ /dev/null @@ -1,11 +0,0 @@ - -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)); diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json deleted file mode 100644 index 56efec12..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json +++ /dev/null @@ -1 +0,0 @@ -[{"hauled_m3":10,"borrow_m3":1000,"blocks":1},{"hauled_m3":0,"borrow_m3":1000,"blocks":0}] \ No newline at end of file diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current deleted file mode 120000 index 0ef2dcea..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current +++ /dev/null @@ -1 +0,0 @@ -C:/Aislo_wt/aislo-b0506/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0 \ No newline at end of file