- 정의 사토장(spoil_bank) 배치 구간 → 부지 · 정의 판 14(이력 판_13) · 화면은 토취장 길 그대로(기준 측점 No + 거리) - 옛 구간 저장분 = 읽을 때 기준 측점(옛 기준 → 시작) · 시작 · 종료 뺌 · 파일은 다음 [저장]까지 그대로 - B06 사토 배분 = 기준 측점에 가장 가까운 측점 하나(가운데면 앞 측점) × 대표 길이(앞뒤 절반) - 바뀌는 값: 담은 양 같음 · 쌓는 측점 구간 여럿 → 하나 · 그 측점 폭 · 면적 커짐 - 시험 test_b06_spoil_fill_allocation(기준 측점 꼴 · 옛 꼴 읽기 · 가까운 측점) · test_52_9 · 화면 ui_checks/54-7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
248 lines
9.2 KiB
Python
248 lines
9.2 KiB
Python
"""사토장 — 용량에서 폭을 정해 측점마다 단면을 세운다 (2026-09-09).
|
|
|
|
⚠ 잠그는 것
|
|
① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다.
|
|
② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기).
|
|
③ 「자동」이면 그 측점의 **성토 쪽**에 선다.
|
|
④ 기준 측점 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**.
|
|
⑤ 사토장 = 기준 측점 한 점(PLAN 54-7) — 가장 가까운 측점 하나 · 옛 구간 저장분은 시작 측점.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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 B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402
|
|
|
|
|
|
def _samples() -> list[dict]:
|
|
"""우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽이다."""
|
|
return [
|
|
{"offset_m": round(offset, 3), "elevation_m": round(100.0 - 0.5 * offset, 4), "valid": True}
|
|
for offset in [x * 0.5 for x in range(-30, 31)]
|
|
]
|
|
|
|
|
|
def _sections(chainages: list[float]) -> list[dict]:
|
|
return [
|
|
{
|
|
"chainage_m": chainage,
|
|
"samples": _samples(),
|
|
"design": compute_cross_design(
|
|
_samples(),
|
|
99.8,
|
|
ground_type="soil",
|
|
section_mode="right_cut",
|
|
ditch_side="right",
|
|
),
|
|
}
|
|
for chainage in chainages
|
|
]
|
|
|
|
|
|
_LONGITUDINAL = {
|
|
"design_profiles": [
|
|
{"samples": [{"chainage_m": float(c), "elevation_m": 99.8} for c in range(0, 201, 20)]}
|
|
]
|
|
}
|
|
|
|
|
|
def _write_site(root: Path, capacity_m3: float, **options) -> None:
|
|
(root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
|
|
(root / "B05_Profile" / "route" / "structures.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"revision": 1,
|
|
"structures": [
|
|
{
|
|
"structure_id": "sp-1",
|
|
"type_id": "spoil_bank",
|
|
"placement": "site",
|
|
"chainage_m": 120.0,
|
|
"options": {"capacity_m3": capacity_m3, **options},
|
|
}
|
|
],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _run(root: Path, capacity_m3: float, **options) -> list[dict]:
|
|
_write_site(root, capacity_m3, **options)
|
|
sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0])
|
|
enforce_spoil_fills(_LONGITUDINAL, sections, root)
|
|
return sections
|
|
|
|
|
|
def _placed(sections: list[dict]) -> float:
|
|
inside = [s for s in sections if s["chainage_m"] == 120.0]
|
|
return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0)
|
|
|
|
|
|
def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None:
|
|
small = _run(tmp_path, 200.0)
|
|
large = _run(tmp_path, 800.0)
|
|
narrow = small[2]["design"]["spoil_fill_width_m"]
|
|
wide = large[2]["design"]["spoil_fill_width_m"]
|
|
assert 0 < narrow < wide
|
|
assert _placed(small) == pytest.approx(200.0, rel=0.02)
|
|
assert _placed(large) == pytest.approx(800.0, rel=0.02)
|
|
|
|
|
|
def test_담을_수_없으면_남은_몫이_값으로_남는다(tmp_path: Path) -> None:
|
|
"""상한에서 멈춘다 — 임의로 더 넓히지 않는다."""
|
|
sections = _run(tmp_path, 1_000_000.0)
|
|
design = sections[2]["design"]
|
|
assert design["spoil_fill_unplaced_m3"] > 0
|
|
assert design["spoil_fill_width_m"] == pytest.approx(design["spoil_fill_max_width_m"])
|
|
|
|
|
|
def test_노선_성토가_그만큼_줄어든다(tmp_path: Path) -> None:
|
|
"""같은 흙을 두 번 세지 않는다(확정 ㉠)."""
|
|
plain = _sections([120.0])[0]["design"]
|
|
sections = _run(tmp_path, 400.0)
|
|
inside = sections[2]["design"]
|
|
assert inside["spoil_fill_area_m2"] > 0
|
|
assert inside["spoil_fill_replaced_fill_m2"] > 0
|
|
assert inside["fill_area_m2"] == pytest.approx(
|
|
plain["fill_area_m2"] - inside["spoil_fill_replaced_fill_m2"], abs=1e-6
|
|
)
|
|
|
|
|
|
def test_자동이면_성토_쪽에_선다(tmp_path: Path) -> None:
|
|
sections = _run(tmp_path, 400.0, side="자동(성토 쪽)")
|
|
# right_cut = 우측 절토 ⇒ 성토는 좌측(+offset).
|
|
assert sections[2]["design"]["spoil_fill_side"] == "left"
|
|
|
|
|
|
def test_기준_측점_밖_측점은_안_건드린다(tmp_path: Path) -> None:
|
|
"""새 측점을 만들지 않고, 기준 측점 밖은 종전 그대로다(확정 ③)."""
|
|
sections = _run(tmp_path, 400.0)
|
|
assert len(sections) == 6
|
|
for section in sections:
|
|
design = section["design"]
|
|
if section["chainage_m"] == 120.0:
|
|
assert design["spoil_fill_area_m2"] > 0
|
|
else:
|
|
assert design.get("spoil_fill_area_m2", 0) == 0
|
|
|
|
|
|
def test_용량이_없으면_아무것도_안_세운다(tmp_path: Path) -> None:
|
|
(tmp_path / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "B05_Profile" / "route" / "structures.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"revision": 1,
|
|
"structures": [
|
|
{
|
|
"structure_id": "sp-1",
|
|
"type_id": "spoil_bank",
|
|
"placement": "interval",
|
|
"start_m": 100.0,
|
|
"end_m": 160.0,
|
|
"options": {},
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
sections = _sections([120.0])
|
|
assert enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) == 0
|
|
assert sections[0]["design"]["spoil_fill_area_m2"] == 0
|
|
|
|
|
|
def test_사토장을_지우면_값도_사라진다(tmp_path: Path) -> None:
|
|
"""⚠ 화면 실측으로 잡은 자리(2026-09-09) — 얹기만 하고 **지우지 않아** 구조물을 없앤 뒤에도
|
|
횡단도에 계속 그려지고 면적표에도 서 있었다.
|
|
"""
|
|
sections = _run(tmp_path, 400.0)
|
|
inside = sections[2]["design"]
|
|
assert inside["spoil_fill_area_m2"] > 0
|
|
plain_fill = _sections([120.0])[0]["design"]["fill_area_m2"]
|
|
|
|
# 구조물을 지운다 — 빈 목록으로 덮어쓴다.
|
|
(tmp_path / "B05_Profile" / "route" / "structures.json").write_text(
|
|
json.dumps({"revision": 2, "structures": []}, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
changed = enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path)
|
|
assert changed > 0, "지운 사토장을 되돌리지 않았다"
|
|
for section in sections:
|
|
design = section["design"]
|
|
# 칸은 남아도 **값이 0** 이어야 한다 — 설계 결과는 사토장이 없어도 칸을 늘 싣는다.
|
|
assert float(design.get("spoil_fill_area_m2") or 0) == 0, section["chainage_m"]
|
|
assert float(design.get("spoil_fill_width_m") or 0) == 0, section["chainage_m"]
|
|
assert design.get("spoil_fill_capacity_m3") in (None, 0), section["chainage_m"]
|
|
# 노선 성토도 원래 값으로 돌아온다 — 사토장 몫을 빼 두었던 것이 복구된다.
|
|
assert sections[2]["design"]["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6)
|
|
|
|
|
|
def _write_raw(root: Path, item: dict) -> None:
|
|
(root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True)
|
|
(root / "B05_Profile" / "route" / "structures.json").write_text(
|
|
json.dumps({"revision": 1, "structures": [item]}, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _spoiled(sections: list[dict]) -> list[float]:
|
|
return [
|
|
s["chainage_m"] for s in sections if float(s["design"].get("spoil_fill_area_m2") or 0) > 0
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("legacy", "station"),
|
|
[
|
|
({"chainage_m": 140.0}, 140.0), # 옛 기준이 있으면 옛 기준
|
|
({}, 100.0), # 없으면 시작
|
|
],
|
|
)
|
|
def test_옛_구간_저장분은_옛_기준_시작_차례로_읽는다(
|
|
tmp_path: Path, legacy: dict, station: float
|
|
) -> None:
|
|
_write_raw(
|
|
tmp_path,
|
|
{
|
|
"structure_id": "sp-1",
|
|
"type_id": "spoil_bank",
|
|
"placement": "interval",
|
|
"start_m": 100.0,
|
|
"end_m": 160.0,
|
|
"options": {"capacity_m3": 200.0},
|
|
**legacy,
|
|
},
|
|
)
|
|
sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0])
|
|
enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path)
|
|
assert _spoiled(sections) == [station]
|
|
|
|
|
|
def test_기준_측점_플러스_거리는_가장_가까운_측점에_선다(tmp_path: Path) -> None:
|
|
_write_raw(
|
|
tmp_path,
|
|
{
|
|
"structure_id": "sp-1",
|
|
"type_id": "spoil_bank",
|
|
"placement": "site",
|
|
"chainage_m": 127.0,
|
|
"options": {"capacity_m3": 200.0},
|
|
},
|
|
)
|
|
sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0])
|
|
enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path)
|
|
assert _spoiled(sections) == [120.0]
|
|
# 대표 길이 = 앞뒤 측점 절반씩 20 m — 부피 = 면적 × 20
|
|
design = sections[2]["design"]
|
|
assert design["spoil_fill_placed_m3"] == pytest.approx(design["spoil_fill_area_m2"] * 20.0)
|