Files
Aislo/B06_Section/B06_Section_Engine_SpoilFill.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

319 lines
14 KiB
Python

"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다.
왜 여기 있나
사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야
그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다.
(측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.)
`enforce_ford_surface_drops` 와 같은 자리·같은 방식이다 — **저장분을 쓰는 시점에**
바로잡고, 저장분과 지금 값이 다를 때만 다시 계산한다.
정하는 것과 안 정하는 것
⚠ **기울기·적치높이 기본값을 지어내지 않는다** — 지식DB
`01_임도/02_상세설계/유용토운반작업장.md` §4 가 「근거에 없다. 사용자 협의 없이
기본값을 만들지 않는다」로 못 박았다. 기울기가 비면 **그 측점의 노선 성토 기울기**를
그대로 쓰고(이미 설계된 값), 높이는 **노면 끝 높이**로 정해진다.
⚠ **용량이 없으면 아무것도 안 세운다** — 폭을 정할 근거가 없다.
⚠ **지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 그 몫은
`unplaced_m3` 로 드러낸다 — 임의로 더 넓히지 않는다.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from common_util.common_util_structure_face_role import structure_face_role
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
_SOLVE_STEPS = 24
#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다.
_MAX_PROBE_WIDTH_M = 1000.0
#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장).
SPOIL_TYPE_ID = "spoil_bank"
#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다.
_SIDE_AUTO = "자동(성토 쪽)"
_SIDE_WORDS = {"좌": "left", "우": "right"}
def _section_at(cross_sections: list[dict[str, Any]], chainage_m: float) -> list[dict]:
"""기준 측점에 가장 가까운 측점 하나(PLAN 54-7 · 가운데면 앞 측점).
**새 측점을 만들지 않는다**(확정 ③).
"""
placed = [section for section in cross_sections if section.get("chainage_m") is not None]
if not placed:
return []
return [
min(
placed,
key=lambda item: (
abs(float(item["chainage_m"]) - chainage_m),
float(item["chainage_m"]),
),
)
]
def _span_of(cross_sections: list[dict[str, Any]], section: dict[str, Any]) -> float:
"""그 측점의 대표 길이(m) — 앞뒤 측점과의 절반씩. 끝 측점은 한쪽만."""
chainages = sorted(
{float(item["chainage_m"]) for item in cross_sections if item.get("chainage_m") is not None}
)
here = float(section["chainage_m"])
index = chainages.index(here)
left = chainages[index - 1] if index else here
right = chainages[index + 1] if index + 1 < len(chainages) else here
return (right - left) / 2
def _has_spoil_value(design: dict[str, Any]) -> bool:
"""그 측점에 **사토장 값이 실제로 남아 있나**. 칸이 0 으로 있는 것은 값이 아니다."""
for key in ("spoil_fill_area_m2", "spoil_fill_width_m", "spoil_fill_capacity_m3"):
try:
if float(design.get(key) or 0.0) > 0:
return True
except (TypeError, ValueError):
continue
return bool(design.get("spoil_fill_structure_id"))
def _side_of(design: dict[str, Any], option: Any) -> str | None:
"""쌓는 쪽 — 「좌·우」면 그대로, 「자동」이면 그 측점의 **성토 쪽**."""
word = str(option or "").strip()
if word in _SIDE_WORDS:
return _SIDE_WORDS[word]
if word and word != _SIDE_AUTO:
return None
mode = str(design.get("section_mode") or "")
for korean, key in _SIDE_WORDS.items():
role, _reason = structure_face_role(mode, korean)
if role == "성토":
return key
return None
def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]:
"""배치된 사토장만 골라 쓰기 좋은 모양으로. 기준 측점·용량이 없으면 뺀다.
기준 측점이 없는 옛 구간 저장분은 시작 측점으로 읽음(PLAN 54-7).
"""
sites: list[dict[str, Any]] = []
for item in structures:
type_id = getattr(item, "type_id", None) or (
item.get("type_id") if isinstance(item, dict) else None
)
if str(type_id) != SPOIL_TYPE_ID:
continue
options = getattr(item, "options", None)
if options is None and isinstance(item, dict):
options = item.get("options")
options = options or {}
chainage = getattr(item, "chainage_m", None)
if isinstance(item, dict):
chainage = item.get("chainage_m")
if chainage is None:
chainage = item.get("start_m")
capacity = options.get("capacity_m3")
if chainage is None or capacity in (None, ""):
continue
try:
capacity_value = float(capacity)
except (TypeError, ValueError):
continue
if capacity_value <= 0:
continue
sites.append(
{
"structure_id": getattr(item, "structure_id", None)
or (item.get("structure_id") if isinstance(item, dict) else None),
"chainage_m": float(chainage),
"capacity_m3": capacity_value,
"side_option": options.get("side"),
"slope_ratio_n": options.get("fill_slope_ratio"),
"extra_distance_m": options.get("extra_distance_m"),
}
)
return sites
def _volume_at(
width_m: float,
sections: list[dict[str, Any]],
spans: list[float],
sides: list[str | None],
slope_ratio_n: Any,
longitudinal: dict[str, Any],
standard: dict[str, Any] | None,
recompute,
) -> tuple[float, list[dict[str, Any] | None]]:
"""그 폭으로 쌓이는 총 부피(㎥)와 측점별 설계. 평균단면적법이 아니라 대표길이 곱이다."""
designs: list[dict[str, Any] | None] = []
total = 0.0
for section, span, side in zip(sections, spans, sides, strict=True):
if side is None or width_m <= 0:
designs.append(None)
continue
design = recompute(section, side, width_m, slope_ratio_n, longitudinal, standard)
designs.append(design)
if design:
total += float(design.get("spoil_fill_area_m2") or 0.0) * span
return total, designs
def enforce_spoil_fills(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""사토장이 선 측점의 설계를 다시 계산한다. 바뀐 측점 수를 돌려준다.
폭은 **구간 하나에 하나** — 용량에 맞춰 이분법으로 찾는다. 상한(지반 샘플이 있는
데까지)에서도 모자라면 그 폭으로 두고 못 담은 몫을 `spoil_fill_unplaced_m3` 로 낸다.
"""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import curve_widening_args
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
stored_berm,
stored_cut_slope,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
try:
_revision, structures = load_structures(str(project_root))
except Exception: # noqa: BLE001 — 정본이 없으면 사토장도 없다
structures = []
sites = spoil_sites(structures)
def recompute(section, side, width_m, slope_ratio_n, longitudinal_data, standard_spec):
design = section.get("design")
if not isinstance(design, dict):
return None
chainage = float(section.get("chainage_m", 0.0))
try:
return compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal_data, chainage),
ground_type=str(design.get("ground_type") or "soil"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard_spec,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(design),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=float(design.get("surface_drop_m") or 0.0),
berm=stored_berm(design),
spoil_fill={
"side": side,
"width_m": width_m,
"slope_ratio_n": slope_ratio_n,
},
**curve_widening_args(section),
)
except (ValueError, KeyError):
return None
changed = 0
# ⚠⚠ **지운 사토장이 그림·수량에 남던 자리**(2026-09-09 화면 실측으로 잡음).
# `enforce_spoil_fills` 는 **얹기만** 했으므로 구조물을 지워도 저장분의 `spoil_fill_*`
# 가 그대로 남아 **횡단도에 계속 그려지고 면적표에도 섰다.**
# ⇒ 사토장이 덮지 않는 측점에 값이 남아 있으면 **사토장 없이 다시 계산**해 지운다.
# (노선 성토도 그때 원래 값으로 돌아온다 — 사토장 몫을 빼 두었기 때문이다.)
covered: set[float] = set()
for site in sites:
for section in _section_at(cross_sections, site["chainage_m"]):
covered.add(float(section["chainage_m"]))
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
continue
chainage = section.get("chainage_m")
if chainage is None or float(chainage) in covered:
continue
# ⚠ **칸이 있는 것과 값이 있는 것은 다르다** — 설계 결과는 사토장이 없어도
# `spoil_fill_area_m2: 0.0` 을 늘 싣는다. 「값이 남아 있는」 측점만 되돌린다.
if not _has_spoil_value(design):
continue
side = _side_of(design, None)
plain = recompute(section, side, 0.0, None, longitudinal, standard) if side else None
if plain is None:
# 다시 계산할 수 없으면 **적어도 칸은 지운다** — 값이 남아 그려지는 것보다 낫다.
for key in [k for k in design if str(k).startswith("spoil_fill_")]:
design.pop(key, None)
# 노선 성토는 사토장 몫을 뺀 값이라, 다시 계산 못 하면 그 몫을 되돌려 준다.
moved = float(design.get("spoil_fill_replaced_fill_m2") or 0.0)
if moved > 0:
design["fill_area_m2"] = round(float(design.get("fill_area_m2") or 0.0) + moved, 4)
changed += 1
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
plain[key] = design[key]
section["design"] = plain
changed += 1
if not sites:
return changed
for site in sites:
sections = _section_at(cross_sections, site["chainage_m"])
if not sections:
continue
spans = [_span_of(cross_sections, section) for section in sections]
sides = [_side_of(section.get("design") or {}, site["side_option"]) for section in sections]
ratio = site["slope_ratio_n"]
def volume(width_m: float):
return _volume_at(
width_m, sections, spans, sides, ratio, longitudinal, standard, recompute
)
# 상한 = 그 구간에서 가장 좁은 측점이 허락하는 폭. 한 측점이라도 지반 샘플이
# 모자라면 거기서 잘리므로, 넓혀도 그 측점은 안 늘어난다.
top_total, top_designs = volume(_MAX_PROBE_WIDTH_M)
limit = min(
(
float(design.get("spoil_fill_max_width_m") or 0.0)
for design in top_designs
if design
),
default=0.0,
)
if limit <= 0:
continue
total, designs = volume(limit)
width = limit
if total > site["capacity_m3"]:
low, high = 0.0, limit
for _ in range(_SOLVE_STEPS):
mid = (low + high) / 2
if volume(mid)[0] < site["capacity_m3"]:
low = mid
else:
high = mid
width = round(high, 4)
total, designs = volume(width)
unplaced = max(site["capacity_m3"] - total, 0.0)
for section, design in zip(sections, designs, strict=True):
if not design:
continue
stored = section.get("design") or {}
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if stored.get(key) is not None:
design[key] = stored[key]
# 구간 전체 값도 측점마다 실어 둔다 — 화면 말풍선·수량이 되짚을 수 있게.
design["spoil_fill_capacity_m3"] = round(site["capacity_m3"], 4)
design["spoil_fill_placed_m3"] = round(total, 4)
design["spoil_fill_unplaced_m3"] = round(unplaced, 4)
design["spoil_fill_structure_id"] = site["structure_id"]
design["spoil_fill_extra_distance_m"] = site["extra_distance_m"]
section["design"] = design
changed += 1
return changed