From 3a5a13b48800c86b82902d9826f641d84248f242 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:31:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=82=AC=ED=86=A0=EC=9E=A5=20?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=EC=97=90=EC=84=9C=20=ED=8F=AD=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=ED=95=B4=20=EC=B8=A1=EC=A0=90=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=84=B8=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자는 「이 구간에 ○㎥」를 정하는데 단면은 폭을 알아야 그려짐. 그 구간 측점을 한꺼번에 보고 **폭 하나**를 이분법으로 찾음(작업장은 폭이 일정함). - `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) --- B06_Section/B06_Section_Engine_SpoilFill.py | 264 ++++++++++++++++++ .../B06_Section_Server_Calc_Prebuild.py | 4 + 2 files changed, 268 insertions(+) create mode 100644 B06_Section/B06_Section_Engine_SpoilFill.py diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py new file mode 100644 index 00000000..5437b3a9 --- /dev/null +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -0,0 +1,264 @@ +"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다. + +왜 여기 있나 + 사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야 + 그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다. + (측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.) + + `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 diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index cb3a260c..22a80983 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -127,6 +127,7 @@ def _enforce_stored_designs( 예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로). 2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. """ + from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills from B06_Section.B06_Section_Router_Design import ( enforce_ford_surface_drops, enforce_pavement_ranges, @@ -134,6 +135,9 @@ def _enforce_stored_designs( enforce_pavement_ranges(longitudinal, sections, project_root, standard) enforce_ford_surface_drops(longitudinal, sections, project_root, standard) + # ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다. + # 맨 뒤에 두면 그 결과 위에 사토장 단면이 얹힌다(2026-09-09). + enforce_spoil_fills(longitudinal, sections, project_root, standard) async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: