"""B05 종단 계획선 선형 산출 오케스트레이터 (지반 추종 직선 분할 + 편집 재구성). [[B05_Profile_Engine_Grade]] 가 확정한 설계 기준과 [[B05_Profile_Engine_Grade_Solver]] 의 수치 계산, [[B05_Profile_Engine_Grade_Alignment]] 의 기하 파생을 묶어 `design_profiles` 배열에 넣을 계획선 한 벌을 만든다. 네 진입점이 있다. - `design_ground_following_profile()` : **1차(기본, 2026-09-02 사용자 확정)**. 모든 측점을 변화점으로 삼아 계획고를 **원지반고 그대로** 두는 폴리라인이다. 종단곡선은 사용자가 [직선화]·틸팅으로 만들 때만 생긴다. 관 측점만 정착하던 옛 방식은 관 사이가 길면 골을 성토로, 마루를 절토로 메워 최대 성토 +9.36m(용화 실측)가 남았다. - `design_pipe_anchored_profile()` : 관 정착 선형(옛 1차). 배수유역도가 산출한 배관 배치 측점을 변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 → 배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다. - `design_alignment_profile()` : **2차(폴백)**. 배관이 없거나 1차 산출이 불가할 때 쓰는 기존 지반 추종 직선 분할 DP 선형. - `rebuild_alignment_profile()`: 사용자 편집 확정 시. **저장된 자동 선형(base_pvi)과 정책을 그대로 재사용**하고 편집 델타만 다시 얹는다. DP를 다시 돌리면 기준선이 흔들려 "원복" 이 원래 위치로 돌아가지 않기 때문이다. """ from collections import Counter from typing import Any import numpy as np from B05_Profile.B05_Profile_Engine_Grade import ( GradeDesignOptions, detect_main_direction, ground_profile, legal_plan_radius_min_m, ) from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( ALIGNMENT_SCHEMA_VERSION, AlignmentPolicy, build_alignment, build_curves, chainage_key, evaluate, ) from B05_Profile.B05_Profile_Engine_Grade_Solver import ( grade_limits, integration_weights, solve_alignment_elevations, station_breakpoints, ) from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT ALIGNMENT_PROFILE_ID = "design_grade_line" # 계획선이 어느 진입점에서 나왔는지 저장본만 보고 가릴 수 있게 값을 나눈다. 1차·2차가 # 같은 `_profile_entry()` 를 쓰다 보니 둘 다 `station_alignment` 로 나가, 사고 조사 때 # 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02). PIPE_ANCHORED_BASIS = "pipe_anchored" ALIGNMENT_BASIS = "station_alignment" # 전체 측점 폴리라인(2026-09-02 사용자 확정) — 계획고 = 지반고. GROUND_POLYLINE_BASIS = "ground_polyline" def infer_station_interval(stations: list[dict[str, Any]]) -> float: """측점 목록에서 가장 흔한 간격을 기준 측점간격으로 본다. 사용자가 추가한 비기준 측점(+18, +15 등)이 섞여 있어도 최빈값이 기준 간격이다. """ gaps: list[float] = [] for previous, current in zip(stations[:-1], stations[1:]): gap = round(float(current["chainage_m"]) - float(previous["chainage_m"]), 1) if gap > 0: gaps.append(gap) if not gaps: return 20.0 return float(Counter(gaps).most_common(1)[0][0]) def _profile_entry( alignment: dict[str, Any], options: GradeDesignOptions, direction: str, balanced: bool, warnings: list[str], basis: str = ALIGNMENT_BASIS, ) -> dict[str, Any]: """`design_profiles` 배열 계약(기존 스키마)에 맞춰 계획선 한 벌을 만든다. B06 횡단 계획고와 B08 CAD 계획선 레이어는 `samples`만 사용하므로, 선형 구조가 바뀌어도 하류 단계는 그대로 동작한다. """ balance = alignment["balance"] grades = [abs(segment["grade_percent"]) for segment in alignment["segments"]] return { "schema_version": ALIGNMENT_SCHEMA_VERSION, "id": ALIGNMENT_PROFILE_ID, "name": "계획선", "basis": basis, "criteria": {**options.as_dict(), "resolved_main_direction": direction}, # 구 스키마 호환: 변화점 목록을 pvis 이름으로도 노출한다. "pvis": alignment["pvi"], "samples": alignment["samples"], "stations": alignment["stations"], "balance_segments": [ { "index": 0, "start_chainage_m": alignment["segments"][0]["from_m"] if alignment["segments"] else 0.0, "end_chainage_m": alignment["segments"][-1]["to_m"] if alignment["segments"] else 0.0, "cut_area_m2": balance["cut_area_m2"], "fill_area_m2": balance["fill_area_m2"], "balance_error_m2": balance["net_area_m2"], } ], "summary": { "cut_area_m2": balance["cut_area_m2"], "fill_area_m2": balance["fill_area_m2"], "balance_error_m2": balance["net_area_m2"], "imbalance_percent": balance["imbalance_percent"], "tolerance_percent": balance["tolerance_percent"], "max_grade_pct": round(max(grades), 6) if grades else 0.0, "vertical_curve_count": sum(1 for curve in alignment["curves"] if not curve["omitted"]), "pvi_count": len(alignment["pvi"]), "balance_segment_count": 1, "balanced": bool(balanced and balance["within_tolerance"]), "main_direction": direction, "suggested_elevation_offset_m": None, "edited_station_count": len(alignment["edits"]["station_offsets"]), "warnings": warnings, }, } def _clearance_at( clearances: dict[float, float], chainage_m: float, tolerance: float = 0.5 ) -> float: """앵커 누가거리에 대응하는 최소 여유(m). 근처에 시설이 없으면 0.""" best = 0.0 closest = tolerance for key, value in clearances.items(): gap = abs(key - chainage_m) if gap <= closest: closest = gap best = value return best def design_ground_following_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, *, station_interval_m: float | None = None, edits: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선. 규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원): - **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다. - **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는 변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다. - 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가 되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다 (`design_pipe_anchored_profile` 과 같은 방식). - 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다 (기존 정책과 같다). 시·종점 오프셋(`start/end_elevation_offset_m`)은 그대로 반영한다. 기본값 0이라 평소에는 양끝도 지반고다. """ options.validate() chainage, ground = ground_profile(longitudinal) total = float(chainage[-1]) if total <= 0: raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") stations = list(longitudinal.get("stations") or []) interval = float(station_interval_m or 0) or infer_station_interval(stations) policy = AlignmentPolicy.from_config( station_interval_m=interval, max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) # 측점 목록이 곧 변화점 목록이다. 측점이 비었거나 양끝이 빠져 있으면 종단 격자를 쓴다. node_s = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] node_s = sorted({round(value, 3) for value in node_s if 0.0 <= value <= total}) if len(node_s) < 2: node_s = [round(float(value), 3) for value in chainage] if node_s[0] > 0.0: node_s.insert(0, 0.0) if node_s[-1] < total: node_s.append(round(total, 3)) base_s = np.array(node_s, dtype=np.float64) base_z = np.interp(base_s, chainage, ground) base_z[0] += options.start_elevation_offset_m base_z[-1] += options.end_elevation_offset_m rise = float(base_z[-1] - base_z[0]) direction, note = ( detect_main_direction(ground, rise) if options.main_direction == "auto" else (options.main_direction, None) ) if note: warnings.append(note) # 대칭 종단곡선은 꼭짓점을 지나지 않는다(중앙종거 |A|·L/8) — 보정 없이 곡선을 넣으면 # 계획고가 지반고에서 뜬다. 호가 측점 세로선과 만나는 점이 지반고가 되도록 꼭짓점을 # 밀어내며, 표고가 움직이면 대수차도 변하므로 수렴할 때까지 되풀이한다. target = base_z.copy() for _ in range(12): curves, _curve_warnings = build_curves(base_s, base_z, policy) error = target - evaluate(base_s, base_z, curves, base_s) error[0] = 0.0 error[-1] = 0.0 if float(np.max(np.abs(error))) < 1e-4: break base_z = base_z + error alignment = build_alignment( base_s=base_s, base_z=base_z, chainage=chainage, ground=ground, stations=stations, policy=policy, edits=dict(edits or {}), ) warnings.extend(alignment["warnings"]) balanced = bool(alignment["balance"]["within_tolerance"]) return alignment, _profile_entry( alignment, options, direction, balanced, warnings, GROUND_POLYLINE_BASIS ) def design_pipe_anchored_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, pipe_chainages: list[float], *, station_interval_m: float | None = None, edits: dict[str, Any] | None = None, pipe_clearances: dict[float, float] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """배관 배치 측점을 변화점으로 삼는 1차 계획선. 기하 규칙(2026-08-03 사용자 확정): **배관 자리(지면선과 배관 세로선의 교점)가 호 위에 있어야 한다.** 시작점·종점과 각 호, 호와 호 사이는 직선이 접선(tangent)으로 잇는다. 대칭 종단곡선은 원래 변화점(꼭짓점)을 지나지 않으므로, 변화점 표고를 반복 보정해 **곡선이 정확히 배관 지반고를 통과**하도록 맞춘다(중앙종거만큼 꼭짓점을 밀어낸다). 호는 **길이 L을 기준**으로 잡는다(`default_curve_length_m`). 변화점 사이 대수차 A가 작아 R을 고정하면 L = R × A 가 변화점마다 널뛰기 때문이다. R = L / A 로 역산해 저장하며 법정 최소반경(`options.min_vertical_radius_m`) 아래로는 내려가지 않게 눌러 준다. 이 R은 편집 델타(curve_radii)로 저장돼 사용자가 B05 테이블에서 그대로 고칠 수 있다. 기울기 위반은 막지 않고 경고로 남긴다 — 배관 위치가 우선이고 조정은 사용자 몫이다. """ options.validate() chainage, ground = ground_profile(longitudinal) total = float(chainage[-1]) if total <= 0: raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") stations = list(longitudinal.get("stations") or []) interval = float(station_interval_m or 0) or infer_station_interval(stations) policy = AlignmentPolicy.from_config( station_interval_m=interval, max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) fixed = ( float(ground[0]) + options.start_elevation_offset_m, float(ground[-1]) + options.end_elevation_offset_m, ) rise = fixed[1] - fixed[0] direction, note = ( detect_main_direction(ground, rise) if options.main_direction == "auto" else (options.main_direction, None) ) if note: warnings.append(note) # 범위 밖·양끝에 붙은 것은 버리고, 서로 붙은 배관(0.5m 미만)은 하나로 본다. margin = 0.5 anchors: list[float] = [] for value in sorted(float(c) for c in pipe_chainages): if value <= margin or value >= total - margin: continue if anchors and value - anchors[-1] < margin: continue anchors.append(round(value, 3)) if not anchors: raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.") base_s = np.array([0.0, *anchors, total], dtype=np.float64) # 목표: 배관 자리 계획고 = 지반고 + **시설 최소 여유**. 지반고에 딱 맞추면 관·구체가 # 들어갈 자리가 없다(2026-08-23 사용자 지시). 여유는 관경+토피/구체높이+토피/월류 # 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다. # 시·종점만 오프셋을 얹는다. target = np.interp(base_s, chainage, ground) if pipe_clearances: for index, value in enumerate(base_s): clearance = _clearance_at(pipe_clearances, float(value)) if clearance > 0: target[index] += clearance target[0] = fixed[0] target[-1] = fixed[1] target_length = float(FOREST_ROAD_PROFILE_ALIGNMENT["default_curve_length_m"]) min_radius = float(options.min_vertical_radius_m) edits = dict(edits or {}) user_radii = dict(edits.get("curve_radii") or {}) def radii_for(nodes_z: np.ndarray) -> dict[str, float]: """현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다).""" spans = base_s[1:] - base_s[:-1] grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0) resolved: dict[str, float] = {} for index in range(1, len(base_s) - 1): delta = abs(float(grades[index] - grades[index - 1])) radius = target_length / delta if delta > 1e-9 else min_radius resolved[chainage_key(base_s[index])] = max(radius, min_radius) return {**resolved, **user_radii} # 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록 # 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다). # 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다. base_z = target.copy() radii = radii_for(base_z) for _ in range(12): radii = radii_for(base_z) curves, _curve_warnings = build_curves(base_s, base_z, policy, radii) plan_at = evaluate(base_s, base_z, curves, base_s) error = target - plan_at error[0] = 0.0 error[-1] = 0.0 if float(np.max(np.abs(error))) < 1e-4: break base_z = base_z + error edits["curve_radii"] = radii alignment = build_alignment( base_s=base_s, base_z=base_z, chainage=chainage, ground=ground, stations=stations, policy=policy, edits=edits, ) warnings.extend(alignment["warnings"]) balanced = bool(alignment["balance"]["within_tolerance"]) return alignment, _profile_entry( alignment, options, direction, balanced, warnings, PIPE_ANCHORED_BASIS ) def design_alignment_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, *, station_interval_m: float | None = None, edits: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """지반 종단을 직선 분할로 근사해 계획선 선형을 새로 만든다. 반환값은 (선형 구조, `design_profiles` 항목) 이다. """ options.validate() chainage, ground = ground_profile(longitudinal) total = float(chainage[-1]) if total <= 0: raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") stations = list(longitudinal.get("stations") or []) interval = float(station_interval_m or 0) or infer_station_interval(stations) policy = AlignmentPolicy.from_config( station_interval_m=interval, max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) fixed = ( float(ground[0]) + options.start_elevation_offset_m, float(ground[-1]) + options.end_elevation_offset_m, ) rise = fixed[1] - fixed[0] direction, note = ( detect_main_direction(ground, rise) if options.main_direction == "auto" else (options.main_direction, None) ) if note: warnings.append(note) up_limit, down_limit = grade_limits( options.max_grade_pct, options.max_reverse_grade_pct, direction ) limit = up_limit if rise >= 0 else down_limit if abs(rise) / total > limit + 1e-9: raise ValueError( f"시·종점 고도차({rise:.2f}m)를 연장 {total:.1f}m에서 기준 기울기 " f"{limit * 100:.1f}% 이내로 연결할 수 없습니다." ) station_chainages = np.array( [float(station["chainage_m"]) for station in stations], dtype=np.float64 ) base_s = station_breakpoints( chainage, ground, station_chainages if len(station_chainages) else chainage, penalty_m2=policy.pvi_penalty_m2, min_segment_stations=policy.min_segment_stations, ) base_z, balanced = solve_alignment_elevations( base_s, chainage, ground, integration_weights(chainage), fixed, up_limit, down_limit, balance_tolerance_percent=policy.balance_tolerance_percent, ) alignment = build_alignment( base_s=base_s, base_z=base_z, chainage=chainage, ground=ground, stations=stations, policy=policy, edits=edits, ) warnings.extend(alignment["warnings"]) return alignment, _profile_entry(alignment, options, direction, balanced, warnings) def rebuild_alignment_profile( longitudinal: dict[str, Any], edits: dict[str, Any] | None, ) -> tuple[dict[str, Any], dict[str, Any]]: """저장된 자동 선형을 기준으로 사용자 편집만 다시 얹는다. 직선 분할 DP를 다시 돌리지 않으므로 편집 델타의 기준선(base_pvi)이 고정되고, 편집 키를 지우면 최초 자동 선형으로 정확히 되돌아간다. """ stored = longitudinal.get("profile_alignment") if not isinstance(stored, dict) or not stored.get("base_pvi"): raise ValueError("저장된 계획선 선형이 없어 편집을 반영할 수 없습니다.") chainage, ground = ground_profile(longitudinal) base = stored["base_pvi"] base_s = np.array([float(item["chainage_m"]) for item in base], dtype=np.float64) base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64) policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) previous = (longitudinal.get("design_profiles") or [{}])[0] alignment = build_alignment( base_s=base_s, base_z=base_z, chainage=chainage, ground=ground, stations=list(longitudinal.get("stations") or []), policy=policy, edits=edits, ) criteria = previous.get("criteria") or {} options = GradeDesignOptions( max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0), min_vertical_radius_m=float(criteria.get("min_vertical_radius_m") or 100.0), min_curve_length_m=float(criteria.get("min_curve_length_m") or 20.0), min_tangent_length_m=float(criteria.get("min_tangent_length_m") or 20.0), vertical_curve_skip_delta_pct=policy.curve_skip_delta_pct, design_speed_kph=int(criteria.get("design_speed_kph") or 20), terrain_type=str(criteria.get("terrain_type") or "normal"), paved=policy.paved, main_direction=str(criteria.get("main_direction") or "auto"), enforce_pipe_clearance=bool(criteria.get("enforce_pipe_clearance") or False), ) direction = str(criteria.get("resolved_main_direction") or "none") return alignment, _profile_entry( alignment, options, direction, alignment["balance"]["within_tolerance"], alignment["warnings"], # 재구성은 저장된 자동 선형을 그대로 쓰므로 출처도 그대로 물려받는다. str(previous.get("basis") or ALIGNMENT_BASIS), )