"""B05 종단 계획선(시공계획고) 산출 엔진. 지반고 종단을 따라가면서 절토량과 성토량이 균형을 이루는 계획선을 만든다. 계획선은 일정 기울기의 직선(tangent)과 기울기 변화점에 삽입한 종단곡선 (포물선)만으로 구성한다. 「임도설치 및 관리 등에 관한 규정」[별표 1-2]의 다-(3)-(나) "시공계획고는 절토량과 성토량이 균형을 이루게 하되" 및 다-(3)-(다) "종단기울기의 변화점에는 종단곡선을 삽입한다"가 근거다. 절·성토 균형은 계획고와 지반고 차이의 적분(A안)으로 판정한다. 횡단 설계가 나온 뒤 실제 단면적 기반 계획선을 추가할 수 있도록 결과는 배열의 한 원소로 반환한다. """ import math from dataclasses import dataclass, field from typing import Any import numpy as np from B05_Profile.B05_Profile_Engine_Grade_Solver import ( balance_boundaries, build_vertical_curves, evaluate_profile, grade_limits, integration_weights, optimize_pvi_elevations, pvi_chainages, segment_weights, ) from config.config_system import ( FOREST_ROAD_PROFILE_CRITERIA, GRADE_MAIN_DIRECTIONS, GRADE_TERRAIN_TYPES, ) GRADE_SCHEMA_VERSION = 1 GRADE_PROFILE_ID = "design_grade_line" # 종단곡선 삽입 후 잔여 오차를 되먹임하는 최적화 반복 횟수 _BALANCE_PASSES = 2 # 시·종점 조정량 제안: 할선법 반복 횟수, 수렴 판정(m²), 제안 상한(m) _OFFSET_SUGGESTION_ITERATIONS = 4 _OFFSET_SUGGESTION_TOLERANCE_M2 = 1.0 _OFFSET_SUGGESTION_MAX_M = 30.0 @dataclass(frozen=True) class GradeDesignOptions: """계획선 설계 기준. 요청 값 → DB 저장 옵션 → config 순으로 결정된다.""" max_grade_pct: float max_reverse_grade_pct: float min_vertical_radius_m: float min_curve_length_m: float min_tangent_length_m: float vertical_curve_skip_delta_pct: float design_speed_kph: int # 기본 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이다. # 잘못된 값이 들어오면 아래 판정 함수들이 "normal" 로 되눌러 법정 상한을 낮게 잡는다. terrain_type: str = "special" paved: bool = False main_direction: str = "auto" balance_segment_length_m: float | None = None start_elevation_offset_m: float = 0.0 end_elevation_offset_m: float = 0.0 # 횡단배수 최소고를 계획선에 강제할지(2026-09-01 사용자 지시). 켜면 배관 자리 계획고를 # 시설 여유(`facility_clearance_m`)만큼 들어 올리고 그 아래로 내리는 편집도 막는다. # 기본은 해제 — 자동설계가 배수 자리마다 계획고를 잡아당겨 제어가 어려웠다. enforce_pipe_clearance: bool = False warnings: tuple[str, ...] = field(default_factory=tuple) def validate(self) -> None: values = { "최대 종단기울기": self.max_grade_pct, "종단곡선 최소 반경": self.min_vertical_radius_m, "종단곡선 최소 길이": self.min_curve_length_m, "최소 직선 길이": self.min_tangent_length_m, } for label, value in values.items(): if not math.isfinite(value) or value <= 0: raise ValueError(f"{label}은 0보다 큰 유한한 값이어야 합니다.") if self.terrain_type not in GRADE_TERRAIN_TYPES: raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") if self.main_direction not in GRADE_MAIN_DIRECTIONS: raise ValueError(f"주 진행방향은 {GRADE_MAIN_DIRECTIONS} 중 하나여야 합니다.") if self.balance_segment_length_m is not None and self.balance_segment_length_m <= 0: raise ValueError("균형 구역 길이는 0보다 큰 값이어야 합니다.") def as_dict(self) -> dict[str, Any]: return { "max_grade_pct": self.max_grade_pct, "max_reverse_grade_pct": self.max_reverse_grade_pct, "min_vertical_radius_m": self.min_vertical_radius_m, "min_curve_length_m": self.min_curve_length_m, "min_tangent_length_m": self.min_tangent_length_m, "vertical_curve_skip_delta_pct": self.vertical_curve_skip_delta_pct, "design_speed_kph": self.design_speed_kph, "terrain_type": self.terrain_type, "paved": self.paved, "main_direction": self.main_direction, "balance_segment_length_m": self.balance_segment_length_m, "start_elevation_offset_m": self.start_elevation_offset_m, "end_elevation_offset_m": self.end_elevation_offset_m, "enforce_pipe_clearance": self.enforce_pipe_clearance, } def resolve_design_speed(grade_class: str, design_speed_kph: int | None = None) -> int: """적용할 설계속도(km/h). 사용자가 고른 값이 있으면 그 값, 없으면 종류별 기본. 임도는 속도를 낼 수 없는 노선이라 기본은 20이고, 간선·산불진화만 30·40을 고를 수 있다(별표2, 2026-08-19 사용자 확정). 허용되지 않는 값이 오면 기본으로 돌린다. """ criteria = FOREST_ROAD_PROFILE_CRITERIA default_speed = int(criteria["grade_to_design_speed"].get(grade_class, 20)) allowed = criteria["selectable_design_speeds"].get(grade_class, (20,)) if design_speed_kph in allowed: return int(design_speed_kph) return default_speed def legal_grade_limit_pct( grade_class: str, terrain_type: str = "normal", paved: bool = False, design_speed_kph: int | None = None, ) -> float: """설계속도 × 지형 구분에 따른 법정 종단기울기 상한(%). 특수지형 + 노면포장인 경우에 한하여 예외 상한까지 허용된다. 경로탐색(느슨한 탐색 제약)과 달리 **위반 판정·계획선 기본값**의 기준이 된다. """ criteria = FOREST_ROAD_PROFILE_CRITERIA terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" speed = resolve_design_speed(grade_class, design_speed_kph) legal_max = float(criteria["design_speed"][speed]["max_grade_pct"][terrain]) if paved and terrain == "special": return float(criteria["paved_exception_grade_pct"]) return legal_max def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") -> float: """설계속도 × 지형 구분에 따른 법정 **평면** 최소곡선반지름(m, 별표2 Ⅰ.2.다.(1)). 경로탐색 제약이 아니라 **위반 표시** 기준이다(2026-09-06 사용자 확정). 설계속도는 이미 확정된 값을 받는다(계획선 옵션이 `resolve_design_speed`로 눌러 둔 값). """ table = FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"] terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" speeds = table.get(int(design_speed_kph)) or table[20] return float(speeds[terrain]) def _pick(*candidates: Any) -> Any: """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" for value in candidates: if value is not None: return value return None def resolve_grade_options( grade_class: str, *, terrain_type: str = "normal", paved: bool = False, main_direction: str = "auto", design_speed_kph: int | None = None, requested: dict[str, Any] | None = None, stored: dict[str, Any] | None = None, ) -> GradeDesignOptions: """임도 등급과 사용자 입력으로 계획선 설계 기준을 확정한다. `stored`에는 **사용자가 명시적으로 입력한 값만** 들어와야 한다. 해석이 끝난 기본값을 저장했다가 되읽으면, 등급·지형을 바꿔도 옛 기본값이 법정값을 이겨 갱신되지 않는다(예: 일반지형 8%가 굳어 특수지형 12%가 반영되지 않음). """ requested = requested or {} stored = stored or {} criteria = FOREST_ROAD_PROFILE_CRITERIA terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" speed = resolve_design_speed(grade_class, design_speed_kph) legal = criteria["design_speed"][speed] legal_max = float(legal["max_grade_pct"][terrain]) ceiling = legal_grade_limit_pct(grade_class, terrain, paved, design_speed_kph) max_grade = float(_pick(requested.get("max_grade_pct"), stored.get("max_grade_pct"), legal_max)) warnings: list[str] = [] if max_grade > ceiling + 1e-9: warnings.append( f"입력한 최대 종단기울기 {max_grade:.1f}%가 법정 상한 {ceiling:.1f}%를 초과합니다." ) return GradeDesignOptions( max_grade_pct=max_grade, max_reverse_grade_pct=float(legal["max_reverse_grade_pct"]), min_vertical_radius_m=float( _pick( requested.get("min_vertical_radius_m"), stored.get("min_vertical_radius_m"), legal["min_vertical_radius_m"], ) ), min_curve_length_m=float(legal["min_curve_length_m"]), min_tangent_length_m=float( _pick( requested.get("min_tangent_length_m"), stored.get("min_tangent_length_m"), criteria["min_tangent_length_m"], ) ), vertical_curve_skip_delta_pct=float(criteria["vertical_curve_skip_delta_pct"]), design_speed_kph=int(speed), terrain_type=terrain, paved=bool(paved), main_direction=(main_direction if main_direction in GRADE_MAIN_DIRECTIONS else "auto"), balance_segment_length_m=_pick( requested.get("balance_segment_length_m"), stored.get("balance_segment_length_m"), criteria["balance_segment_length_m"], ), start_elevation_offset_m=float( _pick( requested.get("start_elevation_offset_m"), stored.get("start_elevation_offset_m"), 0.0, ) ), end_elevation_offset_m=float( _pick( requested.get("end_elevation_offset_m"), stored.get("end_elevation_offset_m"), 0.0 ) ), enforce_pipe_clearance=bool( _pick( requested.get("enforce_pipe_clearance"), stored.get("enforce_pipe_clearance"), False, ) ), warnings=tuple(warnings), ) def ground_profile(longitudinal: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]: """종단 샘플에서 (chainage, 지반고) 배열을 만든다. 결측은 선형 보간한다.""" samples = longitudinal.get("samples") or [] chainage = np.array([float(s.get("chainage_m", 0.0)) for s in samples], dtype=np.float64) raw = np.array( [ float(s["elevation_m"]) if s.get("valid", True) and s.get("elevation_m") is not None else np.nan for s in samples ], dtype=np.float64, ) order = np.argsort(chainage, kind="stable") chainage, raw = chainage[order], raw[order] finite = np.isfinite(raw) if finite.sum() < 2 or len(chainage) < 2: raise ValueError("계획선을 만들기에 유효한 종단 표고 샘플이 부족합니다.") ground = np.interp(chainage, chainage[finite], raw[finite]) return chainage, ground def detect_main_direction(ground: np.ndarray, rise: float) -> tuple[str, str | None]: """지반 종단 형상에서 역기울기 판정 기준이 되는 주 진행방향을 정한다. 시·종점 고도차만으로 부호를 보면 V자(계곡 횡단)·Λ자(능선 통과) 노선에서 오판한다. 고도차가 전체 기복(최고−최저)에 비해 충분히 클 때만 한 방향으로 오르내리는 노선으로 보고, 그렇지 않으면 역기울기를 적용하지 않는다. 반환값은 (방향, 사용자 안내 문구 또는 None) 이다. """ relief = float(np.max(ground) - np.min(ground)) if relief <= 1e-6: return "none", None ratio = abs(rise) / relief if ratio >= float(FOREST_ROAD_PROFILE_CRITERIA["main_direction_monotone_ratio"]): return ("ascending" if rise > 0 else "descending"), None return "none", ( f"시·종점 고도차({rise:+.1f}m)가 전체 기복({relief:.1f}m)에 비해 작아 주 진행방향을 " "특정할 수 없습니다(계곡 횡단·능선 통과 형상). 역기울기 상한 대신 양방향 모두 " "순기울기 상한을 적용했습니다." ) def _segment_metrics( chainage: np.ndarray, difference: np.ndarray, boundaries: np.ndarray, ) -> list[dict[str, Any]]: """구역별 절토·성토 면적과 균형 오차를 계산한다.""" metrics: list[dict[str, Any]] = [] for index, (lower, upper) in enumerate(zip(boundaries[:-1], boundaries[1:])): mask, local_weight = segment_weights(chainage, float(lower), float(upper)) local_difference = difference[mask] cut = float(local_weight[local_difference < 0] @ -local_difference[local_difference < 0]) fill = float(local_weight[local_difference > 0] @ local_difference[local_difference > 0]) metrics.append( { "index": index, "start_chainage_m": round(float(lower), 6), "end_chainage_m": round(float(upper), 6), "cut_area_m2": round(cut, 6), "fill_area_m2": round(fill, 6), "balance_error_m2": round(fill - cut, 6), } ) return metrics def _balance_error( chainage: np.ndarray, ground: np.ndarray, weights: np.ndarray, boundaries: np.ndarray, fixed: tuple[float, float], options: GradeDesignOptions, limits: tuple[float, float], ) -> float: """주어진 시·종점 고도로 풀었을 때의 전체 절·성토 적분 오차(m²).""" pvi_s, pvi_z, curves, _converged, _warnings = _solve_balanced_profile( chainage, ground, weights, boundaries, fixed, options, limits ) difference = evaluate_profile(pvi_s, pvi_z, curves, chainage) - ground return float(weights @ difference) def _suggest_elevation_offset( chainage: np.ndarray, ground: np.ndarray, weights: np.ndarray, boundaries: np.ndarray, options: GradeDesignOptions, limits: tuple[float, float], current_error: float, ) -> float | None: """시·종점을 같은 양만큼 올리거나 내려 균형을 맞출 수 있는 조정량을 추정한다. BP·EP가 능선 정상에 놓이면 계획선이 지반 위로만 떠서 절토가 원천적으로 나오지 않는다. 시·종점을 δ만큼 내리면 적분 오차가 대략 δ·L 만큼 줄어드는 성질을 초기 추정으로 삼고, 실제 해로 두 번 보정(할선법)한다. 반환값은 사용자에게 제안만 하며 자동 적용하지 않는다(시·종점은 기존 도로와의 접속점이기 때문). """ total = float(chainage[-1]) or 1.0 guess = -current_error / total previous_offset, previous_error = 0.0, current_error for _ in range(_OFFSET_SUGGESTION_ITERATIONS): try: error = _balance_error( chainage, ground, weights, boundaries, ( float(ground[0]) + guess, float(ground[-1]) + guess, ), options, limits, ) except (ValueError, np.linalg.LinAlgError): return None if abs(error) <= _OFFSET_SUGGESTION_TOLERANCE_M2: return guess slope = (error - previous_error) / (guess - previous_offset or 1e-9) if abs(slope) < 1e-9: return None previous_offset, previous_error = guess, error guess = guess - error / slope if not math.isfinite(guess) or abs(guess) > _OFFSET_SUGGESTION_MAX_M: return None return guess if abs(previous_error) < abs(current_error) else None def _solve_balanced_profile( chainage: np.ndarray, ground: np.ndarray, weights: np.ndarray, boundaries: np.ndarray, fixed: tuple[float, float], options: GradeDesignOptions, limits: tuple[float, float], ) -> tuple[np.ndarray, np.ndarray, list[dict[str, Any]], bool, list[str]]: """주어진 균형 구역 구성으로 PVI 표고와 종단곡선을 확정한다. 종단곡선(포물선)은 직선 폴리라인 대비 A·L²/24 만큼 면적을 바꾸므로, 그 값을 다음 반복의 목표 적분값으로 되먹여 곡선까지 반영한 최종 적분이 0이 되게 한다. """ total = float(chainage[-1]) pvi_s = pvi_chainages(boundaries, total, options.min_tangent_length_m) up_limit, down_limit = limits targets = np.zeros(len(boundaries) - 1, dtype=np.float64) pvi_z = np.zeros_like(pvi_s) curves: list[dict[str, Any]] = [] curve_warnings: list[str] = [] converged = True for _pass in range(_BALANCE_PASSES): pvi_z, converged = optimize_pvi_elevations( pvi_s, chainage, ground, weights, boundaries, targets, fixed, up_limit, down_limit ) curves, curve_warnings = build_vertical_curves( pvi_s, pvi_z, radius_m=options.min_vertical_radius_m, min_curve_length_m=options.min_curve_length_m, skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, ) corrections = np.zeros_like(targets) for curve in curves: segment = int( np.clip( np.searchsorted(boundaries, curve["chainage_m"], "right") - 1, 0, len(targets) - 1, ) ) corrections[segment] -= curve["area_offset_m2"] if np.allclose(corrections, targets, atol=1e-6): break targets = corrections return pvi_s, pvi_z, curves, converged, curve_warnings def design_grade_line(longitudinal: dict[str, Any], options: GradeDesignOptions) -> dict[str, Any]: """종단 지반고에서 절·성토 균형 계획선을 산출한다. 반환 dict는 longitudinal.json의 `design_profiles` 배열에 그대로 넣는다. """ options.validate() chainage, ground = ground_profile(longitudinal) total = float(chainage[-1]) if total <= 0: raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") weights = integration_weights(chainage) boundaries = balance_boundaries(total, options.balance_segment_length_m) fixed = ( float(ground[0]) + options.start_elevation_offset_m, float(ground[-1]) + options.end_elevation_offset_m, ) rise = fixed[1] - fixed[0] warnings = list(options.warnings) direction, direction_note = ( detect_main_direction(ground, rise) if options.main_direction == "auto" else (options.main_direction, None) ) if direction_note: warnings.append(direction_note) limits = grade_limits(options.max_grade_pct, options.max_reverse_grade_pct, direction) up_limit, down_limit = limits 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}% 이내로 연결할 수 없습니다." ) pvi_s, pvi_z, curves, converged, curve_warnings = _solve_balanced_profile( chainage, ground, weights, boundaries, fixed, options, limits ) if not converged and len(boundaries) > 2: # 구역별 정확 균형이 기울기 기준과 양립하지 않는 경우: 절·성토 균형은 법정 # 요건(다-(3)-(나))이므로, 구역 분할을 포기하고 노선 전체 1구역으로 다시 # 풀어 정확 균형을 우선한다. 그래도 안 되면 그 결과(근사 균형)를 그대로 쓴다. full_boundaries = np.array([0.0, total], dtype=np.float64) full_pvi_s, full_pvi_z, full_curves, full_converged, full_curve_warnings = ( _solve_balanced_profile( chainage, ground, weights, full_boundaries, fixed, options, limits ) ) if full_converged: warnings.append( f"요청한 균형 구역 길이({options.balance_segment_length_m:.0f}m)로는 기울기 " "기준을 지키며 정확히 균형시킬 수 없어, 노선 전체 1구역 균형으로 대체했습니다." ) boundaries, pvi_s, pvi_z, curves, converged, curve_warnings = ( full_boundaries, full_pvi_s, full_pvi_z, full_curves, full_converged, full_curve_warnings, ) warnings.extend(curve_warnings) plan = evaluate_profile(pvi_s, pvi_z, curves, chainage) difference = plan - ground segments = _segment_metrics(chainage, difference, boundaries) # 균형이 맞지 않고 시·종점이 지반고에 고정돼 있으면, 어느 정도 조정하면 균형이 # 맞는지 계산해 제안한다. 시·종점은 기존 도로 접속점이라 자동 적용하지 않는다. suggested_offset: float | None = None if not converged: warnings.append( "기울기 기준을 지키면서 절·성토를 정확히 균형시킬 수 없어 근사 균형을 적용했습니다." ) pinned = ( abs(options.start_elevation_offset_m) < 1e-9 and abs(options.end_elevation_offset_m) < 1e-9 ) if pinned: suggested_offset = _suggest_elevation_offset( chainage, ground, weights, boundaries, options, limits, float(weights @ difference) ) if suggested_offset is not None: warnings.append( f"시·종점 계획고를 각각 {suggested_offset:+.1f}m 조정하면 균형을 맞출 수 " "있습니다(시점·종점 계획고 조정 입력란)." ) else: warnings.append("시·종점 계획고 조정 또는 기울기·지형 구분 기준을 확인하세요.") grades = (pvi_z[1:] - pvi_z[:-1]) / (pvi_s[1:] - pvi_s[:-1]) max_grade_pct = float(np.max(np.abs(grades)) * 100.0) if len(grades) else 0.0 if max_grade_pct > options.max_grade_pct + 1e-6: warnings.append( f"산출된 최대 종단기울기 {max_grade_pct:.2f}%가 기준 " f"{options.max_grade_pct:.2f}%를 초과합니다." ) curve_by_index = {curve["index"]: curve for curve in curves} pvis = [ { "chainage_m": round(float(pvi_s[index]), 6), "elevation_m": round(float(pvi_z[index]), 6), "kind": "bp" if index == 0 else "ep" if index == len(pvi_s) - 1 else "pvi", "grade_in_pct": round(float(grades[index - 1]) * 100.0, 6) if index > 0 else None, "grade_out_pct": round(float(grades[index]) * 100.0, 6) if index < len(grades) else None, "radius_m": round(float(curve_by_index[index]["radius_m"]), 6) if index in curve_by_index else None, "curve_length_m": round(float(curve_by_index[index]["length_m"]), 6) if index in curve_by_index else None, } for index in range(len(pvi_s)) ] station_chainage = np.array( [float(station["chainage_m"]) for station in longitudinal.get("stations") or []], dtype=np.float64, ) station_plan = ( evaluate_profile(pvi_s, pvi_z, curves, station_chainage) if len(station_chainage) else np.array([]) ) station_ground = ( np.interp(station_chainage, chainage, ground) if len(station_chainage) else np.array([]) ) stations = [ { "station_id": station.get("station_id"), "chainage_m": round(float(station_chainage[index]), 6), "plan_elevation_m": round(float(station_plan[index]), 6), "ground_elevation_m": round(float(station_ground[index]), 6), "cut_m": round(max(float(station_ground[index] - station_plan[index]), 0.0), 6), "fill_m": round(max(float(station_plan[index] - station_ground[index]), 0.0), 6), } for index, station in enumerate(longitudinal.get("stations") or []) ] cut_area = float(weights[difference < 0] @ -difference[difference < 0]) fill_area = float(weights[difference > 0] @ difference[difference > 0]) return { "schema_version": GRADE_SCHEMA_VERSION, "id": GRADE_PROFILE_ID, "name": "계획선", "basis": "longitudinal_balance", "criteria": {**options.as_dict(), "resolved_main_direction": direction}, "pvis": pvis, "samples": [ { "chainage_m": round(float(chainage[index]), 6), "elevation_m": round(float(plan[index]), 6), "ground_elevation_m": round(float(ground[index]), 6), "difference_m": round(float(difference[index]), 6), } for index in range(len(chainage)) ], "stations": stations, "balance_segments": segments, "summary": { "cut_area_m2": round(cut_area, 6), "fill_area_m2": round(fill_area, 6), "balance_error_m2": round(fill_area - cut_area, 6), "max_grade_pct": round(max_grade_pct, 6), "vertical_curve_count": len(curves), "pvi_count": len(pvi_s), "balance_segment_count": len(segments), "balanced": bool(converged), "main_direction": direction, "suggested_elevation_offset_m": ( round(float(suggested_offset), 3) if suggested_offset is not None else None ), "warnings": warnings, }, }