"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다. 왜 여기 있나 사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야 그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다. (측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.) `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 _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]]: """배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다.""" 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 — 정본이 없으면 사토장도 없다 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 _sections_in(cross_sections, site["start_m"], site["end_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 = _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