Files
Aislo/B05_Profile/B05_Profile_Engine_Grade.py
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:18:57 +09:00

635 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
},
}