- 정의 「상세 내용」 = 폭만 · depth_m 표시 끔(옛 구간 값 = 기본값으로 읽음 · 새 기본 1 m)
- 짝: BermSpec 쪽마다 깊이 · side_berm ↔ sideBerm · 설계 결과 berm_depth_{side}_m 되싣기(파이썬 ↔ TS)
- 저장: patch berm_depth_{side}_m(≥ 0) · 재계산 살림 목록 짝(RD ↔ CR) · 세션 bermdepth
- 서버 [저장] 다시 셈 = 구조물 정본 소단 구간(load_berm_spans) + 측점 깊이 · 구간 지우면 소단 없음
- 고름: 소단 수평 단 = 높이만(▲ 0 에서 멈춤 · ▼ 사면 끝 앞 멈춤 · ◀▶ 막힘) · 사면 = 가로 · 노면 = 종단
- 고름을 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각
- 새 시험 test_194_1_berm_depth + helper · test_178_2 · test_54_26 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
209 lines
8.6 KiB
Python
209 lines
8.6 KiB
Python
"""178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 — 파이썬 · TS 거울.
|
||
|
||
짝: `common_util_cross_berm.py` ↔ `.ts` · `B06_Section_Engine_Design_Geometry.py` ↔
|
||
`common_util_cross_design_geometry.ts`. 절토 소단은 끔 · 깊이 0 미만 = 0(도로보다 높을 수 없음).
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||
from common_util.common_util_cross_berm import BermSpec, fill_profile_points
|
||
from config.config_system import STANDARD_CROSS_SECTION
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||
|
||
|
||
def _ground(slope: float) -> list[dict]:
|
||
return [
|
||
{"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True}
|
||
for o in range(-40, 41)
|
||
]
|
||
|
||
|
||
def _case(name, slope, z, mode, berm):
|
||
return {"name": name, "samples": _ground(slope), "z": z, "mode": mode, "berm": berm}
|
||
|
||
|
||
# 좌절토 = 우측 성토(지반이 오른쪽으로 내려감)
|
||
CASES = [
|
||
_case("깊이 1.0 · 폭 0.5", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 1.0)),
|
||
_case("깊이 0 = 노면 끝 높이", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 0.0)),
|
||
_case("깊이 음수 = 0", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, -2.0)),
|
||
_case("깊이 > 사면 높이 = 소단 없음", 0.15, 100.0, "left_cut", (0.5, 3.0, 0.0, 5.0)),
|
||
_case("안쪽 기울기 5°", 0.6, 100.0, "left_cut", (1.0, 3.0, 5.0, 1.5)),
|
||
_case("소단 없음", 0.6, 100.0, "left_cut", None),
|
||
]
|
||
|
||
|
||
def _python(case):
|
||
berm = BermSpec(*case["berm"]) if case["berm"] else None
|
||
return compute_cross_design(
|
||
case["samples"],
|
||
case["z"],
|
||
standard=STANDARD_CROSS_SECTION,
|
||
ground_type="soil",
|
||
section_mode=case["mode"],
|
||
ditch_side="left",
|
||
berm=berm,
|
||
)
|
||
|
||
|
||
_RUNNER = """
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
import { computeCrossDesign } from "./common_util_cross_design.js";
|
||
import { fillProfilePoints } from "./common_util_cross_berm.js";
|
||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||
const designs = input.cases.map((c) => computeCrossDesign(c.samples, c.z, {
|
||
standard: input.standard, groundType: "soil", sectionMode: c.mode, ditchSide: "left",
|
||
berm: c.berm ? { widthM: c.berm[0], intervalM: c.berm[1], slopeDeg: c.berm[2], depthM: c.berm[3] } : null,
|
||
}));
|
||
const profile = fillProfilePoints(4, 99.9, 1.5, { widthM: 0.5, intervalM: 3, slopeDeg: 0, depthM: 1 }, 20, 100);
|
||
writeFileSync(process.argv[3], JSON.stringify({ designs, profile }));
|
||
"""
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def ts_results(tmp_path_factory):
|
||
if not TSC.is_file():
|
||
pytest.skip("프론트엔드 의존성 미설치")
|
||
tmp = tmp_path_factory.mktemp("berm")
|
||
out = tmp / "js"
|
||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||
[
|
||
"node",
|
||
str(TSC),
|
||
str(ROOT / "common_util" / "common_util_cross_design.ts"),
|
||
"--outDir",
|
||
str(out),
|
||
"--module",
|
||
"esnext",
|
||
"--target",
|
||
"es2022",
|
||
"--moduleResolution",
|
||
"bundler",
|
||
"--ignoreConfig",
|
||
],
|
||
cwd=str(ROOT),
|
||
check=True,
|
||
capture_output=True,
|
||
)
|
||
for emitted in out.glob("*.js"):
|
||
emitted.write_text(
|
||
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||
encoding="utf-8",
|
||
)
|
||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||
src, dst = tmp / "in.json", tmp / "out.json"
|
||
src.write_text(
|
||
json.dumps({"cases": CASES, "standard": STANDARD_CROSS_SECTION}, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
subprocess.run( # noqa: S603
|
||
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
|
||
)
|
||
return json.loads(dst.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _flats(design: dict) -> list[tuple[float, float, float]]:
|
||
"""성토(오른쪽 · 음수 오프셋) 노면 끝 밖의 평탄 · 완만 도막 [(안 오프셋, 바깥 오프셋, 표고)]."""
|
||
edge = design["road_edges"]["right"]["offset_m"]
|
||
line = sorted(design["design_line"], key=lambda p: -p["offset_m"]) # 노면 → 바깥
|
||
found: list[tuple[float, float, float]] = []
|
||
for a, b in zip(line, line[1:], strict=False):
|
||
run = a["offset_m"] - b["offset_m"]
|
||
if a["offset_m"] > edge + 1e-6 or run <= 1e-6:
|
||
continue
|
||
if abs(b["elevation_m"] - a["elevation_m"]) / run < 0.1: # 소단(≤ 5°) · 지반 0.15 는 빠짐
|
||
if found and abs(found[-1][1] - a["offset_m"]) < 1e-6: # 이어진 도막은 하나로
|
||
found[-1] = (found[-1][0], b["offset_m"], found[-1][2])
|
||
else:
|
||
found.append((a["offset_m"], b["offset_m"], a["elevation_m"]))
|
||
return found
|
||
|
||
|
||
def _close(left, right, path=""):
|
||
if isinstance(left, dict):
|
||
assert set(left) == set(right), f"{path} 키 다름"
|
||
for key in left:
|
||
_close(left[key], right[key], f"{path}.{key}")
|
||
elif isinstance(left, list):
|
||
assert len(left) == len(right), f"{path} 길이 {len(left)} vs {len(right)}"
|
||
for i, (a, b) in enumerate(zip(left, right, strict=True)):
|
||
_close(a, b, f"{path}[{i}]")
|
||
elif isinstance(left, (int, float)) and not isinstance(left, bool):
|
||
assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}"
|
||
else:
|
||
assert left == right, f"{path}: {left!r} vs {right!r}"
|
||
|
||
|
||
def test_파이썬_TS_설계선_같음(ts_results):
|
||
for case, ts in zip(CASES, ts_results["designs"], strict=True):
|
||
_close(_python(case), ts, case["name"])
|
||
|
||
|
||
def test_사면_꼭짓점_거울():
|
||
py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100)
|
||
assert py[1] == pytest.approx((4 + 0.9 * 1.5, 99.0)) # 노면 끝 100 − 1.0
|
||
assert py[2] == pytest.approx((4 + 0.9 * 1.5 + 0.5, 99.0)) # 폭 0.5 수평
|
||
assert len(py) == 4 # 한 단만 · 그 아래 같은 경사로 끝까지
|
||
|
||
|
||
def test_사면_꼭짓점_TS_같음(ts_results):
|
||
py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100)
|
||
_close([list(p) for p in py], ts_results["profile"], "profile")
|
||
|
||
|
||
def test_한_단_깊이_폭():
|
||
by = {case["name"]: _python(case) for case in CASES}
|
||
edge_z = by["깊이 1.0 · 폭 0.5"]["road_edges"]["right"]["elevation_m"]
|
||
flats = _flats(by["깊이 1.0 · 폭 0.5"])
|
||
assert len(flats) == 1
|
||
inner, outer, z = flats[0]
|
||
assert z == pytest.approx(edge_z - 1.0, abs=1e-3)
|
||
assert inner - outer == pytest.approx(0.5, abs=1e-3)
|
||
assert by["깊이 1.0 · 폭 0.5"]["berm"]["depth_m"] == 1.0
|
||
|
||
zero = _flats(by["깊이 0 = 노면 끝 높이"])
|
||
assert len(zero) == 1 and zero[0][2] == pytest.approx(edge_z, abs=1e-3)
|
||
assert by["깊이 음수 = 0"]["design_line"] == by["깊이 0 = 노면 끝 높이"]["design_line"]
|
||
assert by["깊이 음수 = 0"]["berm"]["depth_m"] == 0.0
|
||
|
||
assert _flats(by["깊이 > 사면 높이 = 소단 없음"]) == []
|
||
assert _flats(by["소단 없음"]) == []
|
||
# 소단이 선 만큼 성토 사면이 바깥으로 밀려 성토 면적이 늘어남
|
||
assert by["깊이 1.0 · 폭 0.5"]["fill_area_m2"] > by["소단 없음"]["fill_area_m2"]
|
||
|
||
|
||
def test_절토_소단_끔():
|
||
"""좌측 절토 사면은 소단을 놓아도 소단 없는 단면과 같음."""
|
||
with_berm = _python(CASES[0])
|
||
plain = _python(CASES[-1])
|
||
left = [p for p in with_berm["design_line"] if p["offset_m"] > 0]
|
||
assert left == [p for p in plain["design_line"] if p["offset_m"] > 0]
|
||
assert with_berm["cut_area_m2"] == plain["cut_area_m2"]
|
||
|
||
|
||
def test_저장분_깊이_읽기():
|
||
"""[저장] 재계산이 읽는 저장분 — 없으면 기본 1.0 · 음수는 0."""
|
||
from B06_Section.B06_Section_Router_Design import stored_berm
|
||
|
||
assert stored_berm({"berm": {"width_m": 0.5}}).depth_m == 1.0
|
||
assert stored_berm({"berm": {"width_m": 0.5, "depth_m": -1}}).depth_m == 0.0
|
||
assert stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}).depth_m == 2.5
|
||
|
||
|
||
def test_좌측_패널_칸_구간_깊이_나름():
|
||
"""옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴).
|
||
|
||
좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`)."""
|
||
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
|
||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||
assert "depthM: spec.depth_m" in refresh
|