"""유토 배분 — **수량은 거르지 않은 계획**, 잔진동 거르기는 **그림에만** (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