- 임도 종류별 하한 표를 config 에 둠, 작업임도는 규정이 없어 0(제한 없음) - 기본 반지름과 하한을 갈라 둠 — 한 값이면 하한 0 이 반지름 0 이 되어 곡선이 안 그려짐 - `/route/plan` 이 `limit_radius_m`·`limit_curve_length_m` 를 함께 내림 - 반지름 칸·곡선 길이 칸·손잡이 끌기가 하한에서 멈추고, 노드 이동은 그 걸음을 되돌림 - 이미 하한을 밑돌던 자리는 그대로 두고 지키던 자리가 넘어가는 것만 막음 - 시험 `resources/tester/test_plan_curve_limits.py` 추가 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
635 lines
27 KiB
Python
635 lines
27 KiB
Python
"""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 plan_radius_limit_m(
|
||
grade_class: str,
|
||
design_speed_kph: int | None = None,
|
||
terrain_type: str = "normal",
|
||
) -> float:
|
||
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
|
||
|
||
위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다
|
||
(2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None)
|
||
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에
|
||
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
|
||
"""
|
||
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
|
||
override = table.get(grade_class)
|
||
if override is not None:
|
||
return float(override)
|
||
return legal_plan_radius_min_m(
|
||
resolve_design_speed(grade_class, design_speed_kph), terrain_type
|
||
)
|
||
|
||
|
||
def plan_curve_length_limit_m(grade_class: str) -> float:
|
||
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
|
||
|
||
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라
|
||
실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정).
|
||
"""
|
||
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
|
||
return float(table.get(grade_class) or 0.0)
|
||
|
||
|
||
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,
|
||
},
|
||
}
|