사용자는 「이 구간에 ○㎥」를 정하는데 단면은 폭을 알아야 그려짐. 그 구간 측점을 한꺼번에 보고 **폭 하나**를 이분법으로 찾음(작업장은 폭이 일정함). - `enforce_spoil_fills` 신설 — `enforce_ford_surface_drops` 와 같은 자리·같은 방식. 자동설계 체인·[저장] 뒤 **맨 마지막**에 돎(앞 보정이 다시 계산하면 사토장 칸이 지워짐) - 「자동」이면 그 측점의 **성토 쪽**에 섬 (`structure_face_role` 재사용) - 상한(지반 샘플이 있는 데까지)에서 멈추고 못 담은 몫은 `spoil_fill_unplaced_m3` 로 냄 - 구간 안 측점만 씀 — **새 측점을 만들지 않음**(사용자 확정 ③) - 용량이 없으면 아무것도 안 세움(폭을 정할 근거가 없음) 실측 — 용량 200㎥·800㎥ 를 넣으면 각각 그만큼 담기고(오차 2% 안), 100만㎥ 를 넣으면 상한 폭에서 멈추고 남은 몫이 값으로 남음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
265 lines
11 KiB
Python
265 lines
11 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 _sections_in(cross_sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]:
|
|
"""구간 안에 든 측점만. **새 측점을 만들지 않는다**(2026-09-09 사용자 확정 ③)."""
|
|
picked = []
|
|
for section in cross_sections:
|
|
chainage = section.get("chainage_m")
|
|
if chainage is None:
|
|
continue
|
|
value = float(chainage)
|
|
if start_m - 1e-6 <= value <= end_m + 1e-6:
|
|
picked.append(section)
|
|
return sorted(picked, key=lambda item: float(item["chainage_m"]))
|
|
|
|
|
|
def _spans(sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[float]:
|
|
"""측점마다 대표 길이(m) — 앞뒤 측점과의 절반씩. 구간 끝은 경계까지만."""
|
|
spans: list[float] = []
|
|
for index, section in enumerate(sections):
|
|
chainage = float(section["chainage_m"])
|
|
left = float(sections[index - 1]["chainage_m"]) if index else max(start_m, chainage)
|
|
right = (
|
|
float(sections[index + 1]["chainage_m"])
|
|
if index + 1 < len(sections)
|
|
else min(end_m, chainage)
|
|
)
|
|
spans.append(max((chainage - left) / 2 + (right - chainage) / 2, 0.0))
|
|
return spans
|
|
|
|
|
|
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]]:
|
|
"""배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다."""
|
|
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 {}
|
|
start = getattr(item, "start_m", None)
|
|
end = getattr(item, "end_m", None)
|
|
if isinstance(item, dict):
|
|
start = item.get("start_m")
|
|
end = item.get("end_m")
|
|
capacity = options.get("capacity_m3")
|
|
if start is None or end 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),
|
|
"start_m": min(float(start), float(end)),
|
|
"end_m": max(float(start), float(end)),
|
|
"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 — 정본이 없으면 사토장도 없다
|
|
return 0
|
|
sites = spoil_sites(structures)
|
|
if not sites:
|
|
return 0
|
|
|
|
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"),
|
|
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
|
|
for site in sites:
|
|
sections = _sections_in(cross_sections, site["start_m"], site["end_m"])
|
|
if not sections:
|
|
continue
|
|
spans = _spans(sections, site["start_m"], site["end_m"])
|
|
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
|