Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
246 lines
9.9 KiB
Python
246 lines
9.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""122 거울 — 좌측 패널 옵션 식 · 파이썬 ↔ TS(`B08_Quantity_Options_Calc.ts`).
|
|
|
|
조작 중 브라우저가 세는 값 = [저장] 뒤 서버가 세는 값이어야 함:
|
|
커플링(관마다 올림(L ÷ 1본) − 1 · 같은 측점 더함) · 암 → 연암 · 경암 칸 · 토공집계표 네 줄.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table
|
|
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
|
|
haul_lists,
|
|
rock_split,
|
|
split_rock_columns,
|
|
summary_rows,
|
|
)
|
|
from B08_Quantity.B08_Quantity_Router_Summary import couplings
|
|
from common_util.common_util_project_settings import mixed_conversion_factors, mixed_rock_c
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
|
|
LENGTHS = [None, 0, 5, 8, 8.0000000001, 16, 17, 22, 23.5, 48]
|
|
ONES = [None, 6, 8, 2.5]
|
|
PCTS = [0, 10, 33.3, 70, 90, 100, 130, -5]
|
|
ROCKS = [0.0, 1.0, 2.3, 47.6, 123.456]
|
|
PIPES = [
|
|
{"chainage_m": 1433.0, "column": "pipe:cp|1200", "length_m": 22},
|
|
{"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 18},
|
|
{"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 9}, # 같은 측점 둘째 관
|
|
{"chainage_m": 1680.0, "column": "pipe:cp|800", "length_m": 16},
|
|
{"chainage_m": 1790.0, "column": "pipe:cp|800", "length_m": None},
|
|
]
|
|
SUMMARY = {
|
|
"rows": [
|
|
{"chainage_m": 1433.0, "values": {"pipe:len|1200": 22, "pipe:cp|1200": 99}},
|
|
{"chainage_m": 1567.0, "values": {"pipe:len|800": 27, "pipe:cp|800": 99}},
|
|
{"chainage_m": 1680.0, "values": {"pipe:len|800": 16, "pipe:cp|800": 99}},
|
|
{"chainage_m": 1790.0, "values": {}},
|
|
],
|
|
"total": {"pipe:len|1200": 22, "pipe:len|800": 43, "pipe:cp|1200": 99, "pipe:cp|800": 99},
|
|
"pipes": PIPES,
|
|
}
|
|
TOTALS = {
|
|
"cut_soil_volume_m3": 10.0,
|
|
"cut_rock_volume_m3": 47.6,
|
|
"ditch_soil_volume_m3": 1.0,
|
|
"ditch_rock_volume_m3": 2.3,
|
|
"adjusted_total_m3": 60.0,
|
|
"fill_volume_m3": 5.0,
|
|
"diverted_m3": 5.0,
|
|
"balance_m3": 50.0,
|
|
"benching_area_m2": 0.0,
|
|
"grading_fill_area_m2": 0.0,
|
|
"grading_cut_area_m2": 0.0,
|
|
"tree_removal_fill_area_m2": 0.0,
|
|
"tree_removal_cut_area_m2": 0.0,
|
|
}
|
|
RECORD = {
|
|
"cut_rock_area_m2": 1.2,
|
|
"cut_rock_volume_m3": 47.6,
|
|
"cut_rock_adjusted_m3": 54.74,
|
|
"ditch_rock_area_m2": 0.18,
|
|
"ditch_rock_volume_m3": 2.3,
|
|
"ditch_rock_adjusted_m3": None,
|
|
}
|
|
|
|
_RUNNER = """
|
|
import { writeFileSync } from "node:fs";
|
|
import * as calc from "./B08_Quantity_Options_Calc.js";
|
|
const input = JSON.parse(process.argv[3]);
|
|
const out = {
|
|
couplings: input.lengths.map((l) => input.ones.map((o) => calc.couplings(l, o))),
|
|
splits: input.rocks.map((r) => input.pcts.map((p) => calc.rockSplit(r, p))),
|
|
records: input.pcts.map((p) => { const r = { ...input.record }; calc.splitRockColumns(r, p); return r; }),
|
|
summary: input.pcts.map((p) => calc.splitRockSummary(input.rows, input.totals, p)),
|
|
recount: input.ones.map((o) => calc.recountCouplings(input.summary, o)),
|
|
};
|
|
writeFileSync(process.argv[2], JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def _ts(tmp_path: Path, payload: dict, runner: str = _RUNNER) -> dict:
|
|
out = tmp_path / "js"
|
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
[
|
|
"node",
|
|
str(TSC),
|
|
str(ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts"),
|
|
"--outDir",
|
|
str(out),
|
|
"--module",
|
|
"esnext",
|
|
"--target",
|
|
"es2022",
|
|
"--moduleResolution",
|
|
"bundler",
|
|
"--ignoreConfig",
|
|
],
|
|
cwd=str(ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
(out / "runner.mjs").write_text(runner, encoding="utf-8")
|
|
result = tmp_path / "out.json"
|
|
subprocess.run( # noqa: S603
|
|
["node", str(out / "runner.mjs"), str(result), json.dumps(payload)],
|
|
cwd=str(ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return json.loads(result.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _py_recount(one: float | None) -> dict:
|
|
"""서버 `_Sheet` 와 같은 모음 — 관마다 couplings · 같은 측점(1 cm) 더함 · 계 = 줄 합."""
|
|
rows = []
|
|
for row in SUMMARY["rows"]:
|
|
values = {k: v for k, v in row["values"].items() if not k.startswith("pipe:cp|")}
|
|
for pipe in PIPES:
|
|
count = couplings(pipe["length_m"], one)
|
|
if count is None or round(pipe["chainage_m"] * 100) != round(row["chainage_m"] * 100):
|
|
continue
|
|
values[pipe["column"]] = values.get(pipe["column"], 0) + count
|
|
rows.append({"chainage_m": row["chainage_m"], "values": values})
|
|
total = {k: v for k, v in SUMMARY["total"].items() if not k.startswith("pipe:cp|")}
|
|
for row in rows:
|
|
for key, value in row["values"].items():
|
|
if key.startswith("pipe:cp|"):
|
|
total[key] = total.get(key, 0) + value
|
|
return {**SUMMARY, "rows": rows, "total": total}
|
|
|
|
|
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
|
def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
|
|
rows = summary_rows(TOTALS, haul_lists(None), 90)
|
|
ts = _ts(
|
|
tmp_path,
|
|
{
|
|
"lengths": LENGTHS,
|
|
"ones": ONES,
|
|
"rocks": ROCKS,
|
|
"pcts": PCTS,
|
|
"record": RECORD,
|
|
"rows": rows,
|
|
"totals": TOTALS,
|
|
"summary": SUMMARY,
|
|
},
|
|
)
|
|
assert ts["couplings"] == [[couplings(l, o) for o in ONES] for l in LENGTHS]
|
|
assert ts["splits"] == [[list(rock_split(r, p)) for p in PCTS] for r in ROCKS]
|
|
for pct, got in zip(PCTS, ts["records"]):
|
|
assert got == split_rock_columns(dict(RECORD), pct), pct
|
|
for pct, got in zip(PCTS, ts["summary"]):
|
|
assert got == summary_rows(TOTALS, haul_lists(None), pct), pct
|
|
for one, got in zip(ONES, ts["recount"]):
|
|
assert got == _py_recount(one), one
|
|
|
|
|
|
# 125-1 — 암 C = 비율 가중 · 토적표(90 : 10 으로 선 서버 값)를 새 비율로 다시 센 TS = 서버가 그 비율로 센 값
|
|
STATIONS = [
|
|
StationArea(0.0, cut_soil_area_m2=1.5, cut_rock_area_m2=2.0, fill_area_m2=0.5),
|
|
StationArea(20.0, cut_soil_area_m2=0.7, cut_rock_area_m2=3.3, ditch_area_m2=0.4),
|
|
StationArea(40.0, cut_rock_area_m2=1.1, fill_area_m2=9.0, cut_rock_kind="blasting_rock"),
|
|
StationArea(52.5, cut_soil_area_m2=2.2, fill_area_m2=1.3),
|
|
]
|
|
HAUL_PLAN = {
|
|
"blocks": [{"bands": [{"equipment": "free_haul", "volume_m3": 12.5, "haul_distance_m": 8}]}]
|
|
}
|
|
|
|
_RUNNER_C = """
|
|
import { writeFileSync } from "node:fs";
|
|
import * as calc from "./B08_Quantity_Options_Calc.js";
|
|
const input = JSON.parse(process.argv[3]);
|
|
const out = {
|
|
c: input.pcts.map((p) => calc.mixedRockC(p, 1.15, 1.3)),
|
|
tables: input.pcts.map((p) => {
|
|
const t = structuredClone(input.table);
|
|
calc.recountRockC(t.rows, t.totals, calc.mixedRockC(p, 1.15, 1.3));
|
|
for (const r of [...t.rows, t.totals]) calc.splitRockColumns(r, p);
|
|
const summary = calc.recountAdjustedSummary(
|
|
calc.splitRockSummary(t.summary, t.totals, p), t.totals, input.freeQ);
|
|
return { rows: t.rows, totals: t.totals, summary };
|
|
}),
|
|
};
|
|
writeFileSync(process.argv[2], JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def _py_table(pct: float) -> dict:
|
|
"""서버 길(`Router_Earthwork`) 그대로 — 가중 C 로 표 · 연암 · 경암 칸 · 집계표."""
|
|
table = build_table(STATIONS, mixed_conversion_factors({"rock_ratios_pct": {"연암": pct}}))
|
|
for record in [*table["rows"], table["totals"]]:
|
|
split_rock_columns(record, pct)
|
|
summary = summary_rows(table["totals"], haul_lists(HAUL_PLAN), pct)
|
|
return {"rows": table["rows"], "totals": table["totals"], "summary": summary}
|
|
|
|
|
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
|
def test_암_C_가중_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
|
|
pcts = [0, 10, 33.3, 50, 90, 100, 130, -5]
|
|
ts = _ts(
|
|
tmp_path,
|
|
{"pcts": pcts, "table": _py_table(90), "freeQ": haul_lists(HAUL_PLAN)["free_haul"]["q"]},
|
|
_RUNNER_C,
|
|
)
|
|
assert ts["c"] == [mixed_rock_c(p, 1.15, 1.3) for p in pcts]
|
|
for pct, got in zip(pcts, ts["tables"]):
|
|
want = json.loads(json.dumps(_py_table(pct)))
|
|
assert got["rows"] == want["rows"] and got["summary"] == want["summary"], pct
|
|
# 합계 — 파이썬 3.12 sum() 은 보정 합산이라 끝자리(1 ulp)만 다를 수 있음
|
|
assert got["totals"] == pytest.approx(want["totals"], rel=1e-12), pct
|
|
|
|
|
|
def test_50_50_이면_보정량이_바뀜() -> None:
|
|
ninety, half = _py_table(90)["totals"], _py_table(50)["totals"]
|
|
assert half["cut_rock_volume_m3"] == ninety["cut_rock_volume_m3"]
|
|
assert half["cut_rock_adjusted_m3"] == pytest.approx(ninety["cut_rock_volume_m3"] * 1.225)
|
|
assert half["adjusted_total_m3"] > ninety["adjusted_total_m3"]
|
|
|
|
|
|
def test_1본_6_m_면_커플링이_바뀜(tmp_path: Path) -> None:
|
|
six, eight = _py_recount(6), _py_recount(8)
|
|
assert six["total"]["pipe:cp|800"] == 2 + 1 + 2 and eight["total"]["pipe:cp|800"] == 2 + 1 + 1
|
|
assert six["total"]["pipe:cp|1200"] == 3 and eight["total"]["pipe:cp|1200"] == 2
|
|
|
|
|
|
def test_식_모양이_두_쪽_같다() -> None:
|
|
ts = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8")
|
|
py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_Earthwork_Summary.py").read_text("utf-8")
|
|
assert "Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0)" in ts
|
|
assert "const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;" in ts
|
|
assert "soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0" in py
|
|
assert "return soft * rippingC + (1 - soft) * blastingC;" in ts
|
|
assert "return soft * ripping_c + (1.0 - soft) * blasting_c" in (
|
|
ROOT / "common_util" / "common_util_project_settings.py"
|
|
).read_text("utf-8")
|
|
assert not re.search(r"^import ", ts, re.M) # 시험이 이 파일만 옮겨 돌림
|