Files
Aislo/resources/tester/test_b06_cross_design_mirror.py
T
eomsangdonandClaude Opus 5.5 3898cb0019 refactor(380-1): 사토장 횡단 그림 · 사토 단면 셈 지움 — 부품 처음값 형상 · 3D 없음
- 부품 처음값 spoil_bank 형상 · 3D = none · 형상 부품 「사토(성토 채움)」 지움
- 사토 단면 셈 파이썬 · TS 짝 지움(Engine_SpoilFill · common_util_spoil_fill · spoil_fill 옵션 · fill_area_beyond)
- 카드 그림 · 면적표 줄 · 모달 띠 · 고름 선 · B07 표 「사토장」 칸 지움(빈 줄로 되돌림)
- B08 수량 · 유토 배분(haul-plan) 미사용 확인 · 남은 사토 값 = [저장] 서버 재계산 · 브라우저 단계 다시 셈으로 정리
- 시험 고쳐 씀: 322_1 · 349_0n · b06_spoil_fill_allocation(남은 값 정리) · spoil_fill_mirror(죽은 길) · 52_9 · cross_design_mirror · 150 · 164 · 179_4 · 316

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-17 14:56:42 +09:00

521 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""횡단 설계선·단면적 거울 테스트 — 파이썬 엔진과 TS 짝이 **같은 값**을 내는지 대조한다.
짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
↔ `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
(단면적은 그 안에서 `..._Engine_Areas.py` ↔ `..._cross_design_areas.ts` 를 탄다)
**계산 자리 규칙**(CLAUDE.md 5장)이 「초기값은 서버, 조작 중은 브라우저, 코드는 한 벌」이라
이 짝은 반드시 같은 값을 내야 한다. 한쪽만 고치면 **화면에 보이는 수량과 저장되는 수량이
갈린다** — 그런데 이 짝에는 거울 테스트가 없었다(2026-09-07 신설).
표준단면 수치는 파이썬 config 를 그대로 TS 에 넘긴다 — 출처가 같아야 기본값 차이가 아니라
**산식 차이**만 잡힌다(배수관 세트 거울 테스트와 같은 방식).
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해
# 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다.
_TOLERANCE = 1e-9
def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dict]:
"""가로 -12m ~ +12m 를 0.5m 간격으로 훑은 지반선. `gap` 구간은 결측으로 둔다."""
samples = []
offset = -12.0
while offset <= 12.0001:
invalid = gap is not None and gap[0] <= offset <= gap[1]
samples.append(
{
"offset_m": round(offset, 3),
"elevation_m": None if invalid else round(100.0 + slope * offset, 4),
"valid": not invalid,
}
)
offset += 0.5
return samples
def _dip_ground() -> list[dict]:
"""양측 절토 · 우측 노면 끝 너머(−4.5 m 밖)에서 원지반이 급히 내려가는 지반(294-3)."""
samples = []
for index in range(49):
offset = -12.0 + index * 0.5
z = 101.05 + 0.2 * offset if offset >= -4.5 else 100.15 + 1.0 * (offset + 4.5)
samples.append({"offset_m": offset, "elevation_m": round(z, 4), "valid": True})
return samples
def _ridge_ground() -> list[dict]:
"""우측 −4 m 까지 노면보다 높은 평지(절토) · 그 너머 급히 내려가는 마루(370-1)."""
return [
{"offset_m": o, "elevation_m": round(100.3 - 0.6 * max(0.0, -o - 4.0), 4), "valid": True}
for o in (-12.0 + i * 0.5 for i in range(49))
]
def _rise_ground() -> list[dict]:
"""좌측 +3 m 까지 노면보다 낮은 평지(성토) · 그 너머 오르막(370-1)."""
return [
{"offset_m": o, "elevation_m": round(99.6 + 0.4 * max(0.0, o - 3.0), 4), "valid": True}
for o in (-12.0 + i * 0.5 for i in range(49))
]
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
CASES = [
{
"name": "토사 좌절토 일반측구",
"samples": _ground(0.35),
"design_elevation_m": 100.4,
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
},
{
"name": "리핑암 우절토 L형측구 2단경사",
"samples": _ground(-0.42),
"design_elevation_m": 99.6,
"options": {
"ground_type": "ripping_rock",
"section_mode": "right_cut",
"ditch_side": "right",
"ditch_type": "l_type",
"rock_boundary_offset_m": -0.8,
"two_stage_slope": True,
},
},
{
"name": "발파암 2단경사 해제",
"samples": _ground(0.55),
"design_elevation_m": 101.2,
"options": {
"ground_type": "blasting_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"rock_boundary_offset_m": -1.4,
"two_stage_slope": False,
},
},
{
# 316-1 — 복합경사 값 없음 = 기본 꺼짐(두 짝 같은 기본)
"name": "발파암 복합경사 값 없음",
"samples": _ground(-0.55),
"design_elevation_m": 99.0,
"options": {
"ground_type": "blasting_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"rock_boundary_offset_m": -1.4,
},
},
{
"name": "포장 + 측구 없음",
"samples": _ground(0.12),
"design_elevation_m": 100.05,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"paved": True,
"ditch_enabled": False,
},
},
{
"name": "세월교 노면 하강",
"samples": _ground(0.2),
"design_elevation_m": 100.6,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"surface_drop_m": 0.45,
},
},
{
"name": "곡선부 확폭 — 바깥쪽 좌",
"samples": _ground(0.28),
"design_elevation_m": 100.3,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"plan_radius_m": 18.0,
"curve_outer_side": "left",
},
},
{
"name": "곡선부 확폭 — 저장된 확폭량 우",
"samples": _ground(-0.3),
"design_elevation_m": 100.9,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"plan_radius_m": 25.0,
"curve_outer_side": "right",
"curve_widening_m": 0.62,
},
},
{
"name": "지반선 결측 구간(사면이 안 닫힐 수 있음)",
"samples": _ground(0.9, gap=(6.0, 12.0)),
"design_elevation_m": 100.0,
"options": {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"},
},
{
# 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 1:0.4 를 1:1.0 으로 눕힘.
# 무릎 아래(암)만 바뀌고 위(토사)는 표준 그대로라, 두 쪽이 같은 지점에서 꺾여야 한다.
"name": "암 절토 경사를 측점에서 바꿈",
"samples": _ground(-0.5),
"design_elevation_m": 99.8,
"options": {
"ground_type": "ripping_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"rock_boundary_offset_m": -1.0,
"two_stage_slope": True,
"cut_slope_ratio": 1.0,
},
},
{
# 설계선 쉬프트(139-6) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘
"name": "노견 확장 좌 0.7 · 우 1.2",
"samples": _ground(0.35),
"design_elevation_m": 100.4,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"shoulder_extend_left_m": 0.7,
"shoulder_extend_right_m": 1.2,
},
},
{
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮긴 노견에서(370-1)
"name": "노견 확장 — 판정은 옮긴 노견",
"samples": _ground(-0.05),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"shoulder_extend_right_m": 3.0,
"shoulder_extend_left_m": 2.5,
},
},
{
# 370-1 — 우 절토 마루를 넘겨 옮김 = 그 쪽 성토 1:n 으로 원지반까지(원지반 직선 아님)
"name": "마루 너머 = 성토 1:n",
"samples": _ridge_ground(),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"shoulder_extend_right_m": 3.0,
},
},
{
# 370-1 — 좌 성토를 오르막으로 옮김 = 그 쪽 절토(측구 자동 판정 따름)
"name": "성 → 절 넘어감",
"samples": _rise_ground(),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"shoulder_extend_left_m": 2.5,
},
},
{
# 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임)
"name": "확폭 좌 + 사면 이동 좌 0.4",
"samples": _ground(0.28),
"design_elevation_m": 100.3,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"plan_radius_m": 18.0,
"curve_outer_side": "left",
"shoulder_extend_left_m": 0.4,
},
},
{
# 286-2 — 물넘이포장 자리 = 측구 없음(좌절토라 자동이면 측구가 섬)
"name": "물넘이포장 자리 측구 없음",
"samples": _ground(0.35),
"design_elevation_m": 100.4,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"paved": True,
"tilted": True,
},
},
{
# 287-4 — 물넘이포장 자리 노면 = 경사각도로 지대 낮은 쪽 한 경사(지반이 좌로 오름 = 우가 낮음)
"name": "물넘이포장 자리 노면 기울임",
"samples": _ground(0.35),
"design_elevation_m": 100.4,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"paved": True,
"tilted": True,
"tilt_pct": 5.2408,
},
},
{
# 294-3 — 양측 절토 · 낮은 쪽(우) 노면을 늘여 원지반이 내려와 만나는 자리까지
"name": "물넘이포장 낮은 쪽 노면 연장",
"samples": _dip_ground(),
"design_elevation_m": 100.4,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"paved": True,
"tilted": True,
"tilt_pct": 5.0,
},
},
]
_TS_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { computeCrossDesign } from "./common_util_cross_design.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const camel = {
ground_type: "groundType",
section_mode: "sectionMode",
ditch_side: "ditchSide",
ditch_type: "ditchType",
paved: "paved",
rock_boundary_offset_m: "rockBoundaryOffsetM",
two_stage_slope: "twoStageSlope",
cut_slope_ratio: "cutSlopeRatio",
ditch_enabled: "ditchEnabled",
ditch_choice: "ditchChoice",
surface_drop_m: "surfaceDropM",
plan_radius_m: "planRadiusM",
curve_outer_side: "curveOuterSide",
curve_widening_m: "curveWideningM",
shoulder_extend_left_m: "shoulderExtendLeftM",
shoulder_extend_right_m: "shoulderExtendRightM",
tilted: "tilted",
tilt_pct: "tiltPct",
};
const results = input.cases.map((item) => {
const options = { standard: input.standard };
for (const [key, value] of Object.entries(item.options)) {
options[camel[key]] = value;
}
return computeCrossDesign(item.samples, item.design_elevation_m, options);
});
writeFileSync(process.argv[3], JSON.stringify(results));
"""
def _python_results() -> list[dict]:
results = []
for case in CASES:
results.append(
compute_cross_design(
case["samples"],
case["design_elevation_m"],
standard=STANDARD_CROSS_SECTION,
**case["options"],
)
)
return results
def _ts_results(tmp_path: Path) -> list[dict]:
"""TS 짝을 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다."""
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_cross_design.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
# tsc 가 낸 상대 import 에는 확장자가 없어 Node ESM 이 못 읽는다(번들러가 붙여 주던 몫).
# 짝 파일이 서로를 부르므로 여기서 `.js` 를 채워 준다.
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(_TS_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(
json.dumps(
{
"cases": [
{
"samples": case["samples"],
"design_elevation_m": case["design_elevation_m"],
"options": case["options"],
}
for case in CASES
],
"standard": STANDARD_CROSS_SECTION,
},
ensure_ascii=False,
),
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"))
def _diff(left, right, path: str = "") -> list[str]:
"""두 값이 갈린 자리를 경로와 함께 모은다 — 어느 항목이 틀렸는지 바로 보이게."""
if isinstance(left, dict) and isinstance(right, dict):
problems = []
for key in sorted(set(left) | set(right)):
if key not in left:
problems.append(f"{path}.{key}: 파이썬에 없음(TS={right[key]!r})")
elif key not in right:
problems.append(f"{path}.{key}: TS 에 없음(파이썬={left[key]!r})")
else:
problems.extend(_diff(left[key], right[key], f"{path}.{key}"))
return problems
if isinstance(left, list) and isinstance(right, list):
if len(left) != len(right):
return [f"{path}: 길이 {len(left)} vs {len(right)}"]
problems = []
for index, (one, other) in enumerate(zip(left, right, strict=True)):
problems.extend(_diff(one, other, f"{path}[{index}]"))
return problems
if isinstance(left, bool) or isinstance(right, bool):
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
if abs(float(left) - float(right)) <= _TOLERANCE:
return []
return [f"{path}: {left!r} vs {right!r}"]
return [] if left == right else [f"{path}: {left!r} vs {right!r}"]
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_횡단_설계선과_단면적이_두_쪽에서_같다(tmp_path: Path) -> None:
expected = _python_results()
actual = _ts_results(tmp_path)
assert len(actual) == len(expected)
problems: list[str] = []
for case, left, right in zip(CASES, expected, actual, strict=True):
problems.extend(_diff(left, right, case["name"]))
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
def test_사토장_단면_칸_없음(tmp_path: Path) -> None:
"""380-1 — 사토장은 횡단에 안 그림 · 사토 단면 셈 지움 — 두 쪽 결과 모두 `spoil_fill_*` 칸 없음."""
for result in [*_python_results(), *_ts_results(tmp_path)]:
assert not [key for key in result if key.startswith("spoil_fill")]
def test_시험이_빈_결과로_통과하지_않는다() -> None:
"""면적이 실제로 나오는 경우가 섞여 있어야 대조가 뜻을 가진다."""
results = _python_results()
assert len(results) == len(CASES)
assert sum(1 for item in results if item["cut_area_m2"] > 0) >= 4
assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1
assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results)
assert any(item["cut_rock_area_m2"] > 0 for item in results)
def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> None:
"""151-2 — 노견 폭 그대로 · 차도 끝이 이동량만큼 바깥 · 확폭 출력엔 이동 몫 안 섞임."""
by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True))
base = compute_cross_design(
_ground(0.35),
100.4,
standard=STANDARD_CROSS_SECTION,
ground_type="soil",
section_mode="left_cut",
ditch_side="left",
)
moved = by_name["노견 확장 좌 0.7 · 우 1.2"]
for side, sign, extend in (("left", 1, 0.7), ("right", -1, 1.2)):
road = moved["road_edges"][side]["offset_m"]
lane = moved["carriageway_edges"][side]["offset_m"]
base_shoulder = (
base["road_edges"][side]["offset_m"] - base["carriageway_edges"][side]["offset_m"]
)
assert abs((road - lane) - base_shoulder) < 1e-9 # 노견 폭 고정
assert abs(lane - (base["carriageway_edges"][side]["offset_m"] + sign * extend)) < 1e-9
assert abs(moved["carriageway_width_m"] - (base["carriageway_width_m"] + 1.9)) < 1e-9
assert moved["widening_left_m"] == 0.0 and moved["widening_right_m"] == 0.0
curve = by_name["곡선부 확폭 — 바깥쪽 좌"]
both = by_name["확폭 좌 + 사면 이동 좌 0.4"]
assert both["widening_left_m"] == curve["widening_left_m"] > 0
assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9
def _outer_slope(result: dict, side: str, skip: float = 0.0) -> float:
"""노면 끝에서 `skip`(측구 폭) 바깥 설계선 한 마디의 기울기(dz / 바깥 거리)."""
sign = 1.0 if side == "left" else -1.0
start_offset = result["road_edges"][side]["offset_m"] + sign * skip
line = sorted(result["design_line"], key=lambda p: sign * p["offset_m"])
index = next(i for i, p in enumerate(line) if abs(p["offset_m"] - start_offset) < 1e-6)
start, nxt = line[index], line[index + 1]
return (nxt["elevation_m"] - start["elevation_m"]) / abs(nxt["offset_m"] - start["offset_m"])
def test_절성_넘어가면_그_쪽_사면_기울기로_원지반까지() -> None:
"""370-1 — 옮긴 노견에서 절/성 판정 · 마루 너머 = 성토 1:n · 성 → 절 = 절토 1:n + 측구 자동."""
by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True))
soil = STANDARD_CROSS_SECTION["soil"]
ridge = by_name["마루 너머 = 성토 1:n"]
assert ridge["section_mode"] == "left_cut" # 우 = 성토
assert ridge["ditch_enabled"] is False # 우 측구 자동 판정 따라 빠짐
assert ridge["fill_area_m2"] > 0
assert abs(_outer_slope(ridge, "right") + 1.0 / soil["fill_slope_ratio"]) < 1e-3
rise = by_name["성 → 절 넘어감"]
assert rise["section_mode"] == "left_cut" # 좌 = 절토
assert rise["ditch_enabled"] is True and rise["ditch_side"] == "left"
assert (
abs(
_outer_slope(rise, "left", soil["ditch"]["top_width_m"]) - 1.0 / soil["cut_slope_ratio"]
)
< 1e-3
)