Files
Aislo/resources/tester/test_b06_spoil_fill_allocation.py
T
eomsangdonandClaude Opus 5.5 8379154730 feat(B05): 사토장 기준 측점 = 토취장과 같은 꼴(54-7) — 기준 측점 한 점 · B06 사토 배분 이어 씀
- 정의 사토장(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
2026-10-06 10:49:15 +09:00

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)