Files
Aislo/B05_Profile/B05_Profile_Engine_Grade.py
T
eomsangdonandClaude Fable 5 8812657022 feat(B05/B02): 표시 정보 정비 + 설계속도 축 도입
- 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라),
  절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데
  곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거.
- [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점
  복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다.
- 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구).
- 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 +
  각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다).
- 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐
  환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영.
- 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h):
  · 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시
    (main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다.
  · 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과
    종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7).
  · B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분).
  · 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설,
    resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에
    설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계).
  · 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다.
- 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%,
  40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h ·
  일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:46:03 +09:00

578 lines
24 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
terrain_type: str = "normal"
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
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,
}
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 _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
)
),
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,
},
}