계획노선 추가
This commit is contained in:
@@ -49,6 +49,30 @@ export interface RouteSolveRequest {
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
terrain_type?: string;
|
||||
main_direction?: string;
|
||||
max_grade_pct?: number | null;
|
||||
min_vertical_radius_m?: number | null;
|
||||
min_tangent_length_m?: number | null;
|
||||
balance_segment_length_m?: number | null;
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
}
|
||||
|
||||
/** 계획선 산출 요약 (실패 시 null) */
|
||||
export interface RouteGradeSummary {
|
||||
id: string;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
max_grade_pct: number;
|
||||
vertical_curve_count: number;
|
||||
pvi_count: number;
|
||||
balance_segment_count: number;
|
||||
balanced: boolean;
|
||||
main_direction: string;
|
||||
suggested_elevation_offset_m: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
|
||||
@@ -62,6 +86,7 @@ export interface RouteSolveResponse {
|
||||
route_data_path: string;
|
||||
longitudinal_length_m: number | null;
|
||||
cross_section_count: number | null;
|
||||
grade_summary: RouteGradeSummary | null;
|
||||
}
|
||||
|
||||
/** 경로 확정 결과 (RouteConfirmResponse) */
|
||||
@@ -107,6 +132,12 @@ export interface RouteLatestResponse {
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
max_grade_pct?: number | null;
|
||||
min_vertical_radius_m?: number | null;
|
||||
min_tangent_length_m?: number | null;
|
||||
balance_segment_length_m?: number | null;
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
"""B05 종단 계획선(시공계획고) 산출 엔진.
|
||||
|
||||
지반고 종단을 따라가면서 절토량과 성토량이 균형을 이루는 계획선을 만든다.
|
||||
계획선은 일정 기울기의 직선(tangent)과 기울기 변화점에 삽입한 종단곡선
|
||||
(포물선)만으로 구성한다. 「임도설치 및 관리 등에 관한 규정」[별표 1-2]의
|
||||
다-(3)-(나) "시공계획고는 절토량과 성토량이 균형을 이루게 하되" 및
|
||||
다-(3)-(다) "종단기울기의 변화점에는 종단곡선을 삽입한다"가 근거다.
|
||||
|
||||
절·성토 균형은 계획고와 지반고 차이의 적분(A안)으로 판정한다. 횡단 설계가
|
||||
나온 뒤 실제 단면적 기반 계획선을 추가할 수 있도록 결과는 배열의 한 원소로
|
||||
반환한다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_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 legal_grade_limit_pct(
|
||||
grade_class: str, terrain_type: str = "normal", paved: bool = False
|
||||
) -> float:
|
||||
"""설계속도 × 지형 구분에 따른 법정 종단기울기 상한(%).
|
||||
|
||||
특수지형 + 노면포장인 경우에 한하여 예외 상한까지 허용된다.
|
||||
경로탐색(느슨한 탐색 제약)과 달리 **위반 판정·계획선 기본값**의 기준이 된다.
|
||||
"""
|
||||
criteria = FOREST_ROAD_PROFILE_CRITERIA
|
||||
terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal"
|
||||
speed = criteria["grade_to_design_speed"].get(grade_class, 20)
|
||||
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",
|
||||
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 = criteria["grade_to_design_speed"].get(grade_class, 20)
|
||||
legal = criteria["design_speed"][speed]
|
||||
legal_max = float(legal["max_grade_pct"][terrain])
|
||||
ceiling = legal_grade_limit_pct(grade_class, terrain, paved)
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""B05 종단 계획선 산출의 수치 계산부 (적분 가중치·PVI 최적화·종단곡선 기하).
|
||||
|
||||
[[B05_wf2_Route_Engine_Grade]] 가 설계 기준을 확정한 뒤 이 모듈의 함수를 호출한다.
|
||||
설계 기준 객체에 의존하지 않고 스칼라 인자만 받아, 기준 해석과 수치 계산을
|
||||
분리한다(700줄 제한에 따른 분할).
|
||||
"""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import LinearConstraint, minimize
|
||||
|
||||
# PVI 간격 산정: 최소 직선길이의 2배 이상을 두되 노선을 최대 이 개수로 나눈다.
|
||||
_MAX_PVI_SEGMENTS = 40
|
||||
# 종단곡선이 인접 곡선과 겹치지 않도록 확보하는 여유 비율
|
||||
_CURVE_OVERLAP_RATIO = 0.9
|
||||
# 구역 균형이 맞았다고 볼 평균 고저차 허용 오차 (m)
|
||||
BALANCE_TOLERANCE_M = 1e-6
|
||||
# 정확 균형이 기울기 기준과 양립하지 않을 때 쓰는 연화(soft) 균형 가중치
|
||||
_SOFT_BALANCE_WEIGHT = 1.0e4
|
||||
_TRUST_OPTIONS = {"maxiter": 1000, "gtol": 1e-10, "xtol": 1e-12}
|
||||
_SLSQP_OPTIONS = {"maxiter": 500, "ftol": 1e-12}
|
||||
|
||||
|
||||
def integration_weights(chainage: np.ndarray) -> np.ndarray:
|
||||
"""사다리꼴 적분용 샘플 가중치(ds)."""
|
||||
weights = np.zeros_like(chainage)
|
||||
weights[1:-1] = (chainage[2:] - chainage[:-2]) / 2.0
|
||||
weights[0] = (chainage[1] - chainage[0]) / 2.0
|
||||
weights[-1] = (chainage[-1] - chainage[-2]) / 2.0
|
||||
return weights
|
||||
|
||||
|
||||
def segment_weights(
|
||||
chainage: np.ndarray, lower: float, upper: float
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""[lower, upper] 구간에 국한된 사다리꼴 적분 가중치를 만든다.
|
||||
|
||||
전역 가중치를 그대로 쓰면 구역 경계 샘플의 반폭이 옆 구역까지 걸쳐 있어
|
||||
구역 면적이 과대 계상된다(가중치 합이 구간 길이와 어긋난다).
|
||||
"""
|
||||
mask = (chainage >= lower - 1e-9) & (chainage <= upper + 1e-9)
|
||||
local = chainage[mask]
|
||||
weights = np.zeros(len(local), dtype=np.float64)
|
||||
if len(local) < 2:
|
||||
return mask, weights
|
||||
weights[1:-1] = (local[2:] - local[:-2]) / 2.0
|
||||
weights[0] = (local[1] - local[0]) / 2.0
|
||||
weights[-1] = (local[-1] - local[-2]) / 2.0
|
||||
return mask, weights
|
||||
|
||||
|
||||
def balance_boundaries(total: float, segment_length: float | None) -> np.ndarray:
|
||||
"""절·성토 균형을 맞출 구역 경계 chainage(시·종점 포함)를 만든다."""
|
||||
if not segment_length or segment_length >= total:
|
||||
return np.array([0.0, total], dtype=np.float64)
|
||||
count = max(1, int(round(total / segment_length)))
|
||||
return np.linspace(0.0, total, count + 1, dtype=np.float64)
|
||||
|
||||
|
||||
def pvi_chainages(boundaries: np.ndarray, total: float, min_tangent: float) -> np.ndarray:
|
||||
"""균형 구역 경계를 반드시 포함하는 PVI 후보 chainage를 배치한다."""
|
||||
spacing = max(min_tangent * 2.0, total / _MAX_PVI_SEGMENTS)
|
||||
nodes: list[float] = [0.0]
|
||||
for start, end in zip(boundaries[:-1], boundaries[1:]):
|
||||
span = float(end - start)
|
||||
count = max(1, int(span // spacing))
|
||||
nodes.extend(float(value) for value in np.linspace(start, end, count + 1)[1:])
|
||||
values = np.unique(np.round(np.array(nodes, dtype=np.float64), 6))
|
||||
return values[(values >= -1e-9) & (values <= total + 1e-9)]
|
||||
|
||||
|
||||
def _interp_matrix(pvi_s: np.ndarray, targets: np.ndarray) -> np.ndarray:
|
||||
"""PVI 표고 벡터를 샘플 chainage의 직선 보간 표고로 옮기는 행렬."""
|
||||
matrix = np.zeros((len(targets), len(pvi_s)), dtype=np.float64)
|
||||
index = np.clip(np.searchsorted(pvi_s, targets, side="right") - 1, 0, len(pvi_s) - 2)
|
||||
span = pvi_s[index + 1] - pvi_s[index]
|
||||
ratio = np.where(span > 0, (targets - pvi_s[index]) / np.where(span > 0, span, 1.0), 0.0)
|
||||
rows = np.arange(len(targets))
|
||||
matrix[rows, index] = 1.0 - ratio
|
||||
matrix[rows, index + 1] = ratio
|
||||
return matrix
|
||||
|
||||
|
||||
def _polyline_area_matrix(pvi_s: np.ndarray, lower: float, upper: float) -> np.ndarray:
|
||||
"""[lower, upper] 구간에서 PVI 직선 폴리라인의 면적 적분 계수 벡터."""
|
||||
coefficients = np.zeros(len(pvi_s), dtype=np.float64)
|
||||
for index in range(len(pvi_s) - 1):
|
||||
start, end = float(pvi_s[index]), float(pvi_s[index + 1])
|
||||
if end <= lower + 1e-9 or start >= upper - 1e-9:
|
||||
continue
|
||||
length = end - start
|
||||
coefficients[index] += length / 2.0
|
||||
coefficients[index + 1] += length / 2.0
|
||||
return coefficients
|
||||
|
||||
|
||||
def grade_limits(
|
||||
max_grade_pct: float, max_reverse_grade_pct: float, main_direction: str
|
||||
) -> tuple[float, float]:
|
||||
"""주 진행방향 기준 (상승 상한, 하강 상한)을 비율로 정한다.
|
||||
|
||||
주 진행방향과 반대인 쪽이 역기울기이므로 더 엄한 상한을 받는다.
|
||||
`none`이면 주 진행방향을 특정할 수 없는 노선(V자·Λ자)이므로 양방향 모두
|
||||
순기울기 상한을 적용한다. 방향 판정은 [[_detect_main_direction]] 이 한다.
|
||||
"""
|
||||
main = max_grade_pct / 100.0
|
||||
reverse = min(max_reverse_grade_pct, max_grade_pct) / 100.0
|
||||
if main_direction == "ascending":
|
||||
return main, reverse
|
||||
if main_direction == "descending":
|
||||
return reverse, main
|
||||
return main, main
|
||||
|
||||
|
||||
def _best_solution(
|
||||
attempts: list[tuple[str, np.ndarray]],
|
||||
objective: Callable[[np.ndarray], float],
|
||||
objective_jac: Callable[[np.ndarray], np.ndarray],
|
||||
hessian: np.ndarray,
|
||||
constraints: list[LinearConstraint],
|
||||
violation: Callable[[np.ndarray], float],
|
||||
) -> tuple[np.ndarray, float]:
|
||||
"""여러 (해법, 초기값) 조합을 시도해 제약 위반이 가장 작은 해를 고른다.
|
||||
|
||||
trust-constr는 활성 제약이 많아지면 Jacobian이 특이해져 엉뚱한 점에서 멈추는
|
||||
경우가 있다. 결과를 직접 검증하고 통과하면 즉시 채택, 아니면 다음 조합으로
|
||||
넘어가되 최소 위반 해를 남긴다.
|
||||
"""
|
||||
best: tuple[np.ndarray, float] | None = None
|
||||
for method, initial in attempts:
|
||||
extra = {"hess": lambda _x: hessian} if method == "trust-constr" else {}
|
||||
settings = _TRUST_OPTIONS if method == "trust-constr" else _SLSQP_OPTIONS
|
||||
try:
|
||||
result = minimize(
|
||||
objective,
|
||||
initial,
|
||||
jac=objective_jac,
|
||||
constraints=constraints,
|
||||
method=method,
|
||||
options=settings,
|
||||
**extra,
|
||||
)
|
||||
except (ValueError, np.linalg.LinAlgError):
|
||||
continue
|
||||
score = violation(result.x)
|
||||
if best is None or score < best[1]:
|
||||
best = (np.asarray(result.x, dtype=np.float64), score)
|
||||
if score <= BALANCE_TOLERANCE_M:
|
||||
break
|
||||
if best is None:
|
||||
return np.asarray(attempts[0][1], dtype=np.float64), float("inf")
|
||||
return best
|
||||
|
||||
|
||||
def optimize_pvi_elevations(
|
||||
pvi_s: np.ndarray,
|
||||
chainage: np.ndarray,
|
||||
ground: np.ndarray,
|
||||
weights: np.ndarray,
|
||||
boundaries: np.ndarray,
|
||||
targets: np.ndarray,
|
||||
fixed: tuple[float, float],
|
||||
up_limit: float,
|
||||
down_limit: float,
|
||||
) -> tuple[np.ndarray, bool]:
|
||||
"""구역별 절·성토 균형을 등식 제약으로 두고 PVI 표고를 최적화한다.
|
||||
|
||||
목적함수는 지반 추종(∫(계획고−지반고)²ds), 등식 제약은 구역별
|
||||
∫(계획고−지반고)ds = target, 부등식 제약은 구간별 종단기울기 상한이다.
|
||||
두 번째 반환값은 정확 균형 달성 여부다.
|
||||
"""
|
||||
matrix = _interp_matrix(pvi_s, chainage)
|
||||
free = np.arange(1, len(pvi_s) - 1)
|
||||
base = np.zeros(len(pvi_s), dtype=np.float64)
|
||||
base[0], base[-1] = fixed
|
||||
if not len(free):
|
||||
return base, True
|
||||
|
||||
matrix_free = matrix[:, free]
|
||||
offset = matrix @ base - ground
|
||||
# 노선 길이로 정규화해 목적함수와 제약의 스케일 차이를 없앤다.
|
||||
scale = float(weights.sum()) or 1.0
|
||||
normalized = weights / scale
|
||||
# 목적함수가 순수 이차식이므로 Hessian을 해석적으로 넘겨 QP로 정확히 푼다.
|
||||
hessian = 2.0 * (matrix_free.T @ (normalized[:, None] * matrix_free))
|
||||
|
||||
def objective(x: np.ndarray) -> float:
|
||||
residual = matrix_free @ x + offset
|
||||
return float(residual @ (normalized * residual))
|
||||
|
||||
def objective_jac(x: np.ndarray) -> np.ndarray:
|
||||
residual = matrix_free @ x + offset
|
||||
return 2.0 * (matrix_free.T @ (normalized * residual))
|
||||
|
||||
equality: list[np.ndarray] = []
|
||||
equality_rhs: list[float] = []
|
||||
for index, (lower, upper) in enumerate(zip(boundaries[:-1], boundaries[1:])):
|
||||
coefficients = _polyline_area_matrix(pvi_s, float(lower), float(upper))
|
||||
mask, local_weights = segment_weights(chainage, float(lower), float(upper))
|
||||
ground_area = float(local_weights @ ground[mask])
|
||||
# 구역 길이로 나누어 면적(m²)이 아닌 평균 고저차(m) 단위로 다룬다.
|
||||
span = max(float(upper - lower), 1e-9)
|
||||
equality.append(coefficients[free] / span)
|
||||
equality_rhs.append(
|
||||
(ground_area + float(targets[index]) - float(coefficients @ base)) / span
|
||||
)
|
||||
equality_matrix = np.vstack(equality)
|
||||
equality_vector = np.array(equality_rhs, dtype=np.float64)
|
||||
|
||||
spans = pvi_s[1:] - pvi_s[:-1]
|
||||
difference = np.zeros((len(spans), len(pvi_s)), dtype=np.float64)
|
||||
rows = np.arange(len(spans))
|
||||
difference[rows, rows] = -1.0 / spans
|
||||
difference[rows, rows + 1] = 1.0 / spans
|
||||
difference_free = difference[:, free]
|
||||
difference_base = difference @ base
|
||||
|
||||
# 종단기울기 상한은 법정 기준이므로 항상 강제 제약으로 둔다.
|
||||
grade_constraint = LinearConstraint(
|
||||
difference_free, -down_limit - difference_base, up_limit - difference_base
|
||||
)
|
||||
balance_constraint = LinearConstraint(equality_matrix, equality_vector, equality_vector)
|
||||
|
||||
def grade_violation(x: np.ndarray) -> float:
|
||||
grades = difference_free @ x + difference_base
|
||||
return float(
|
||||
max(np.max(grades - up_limit, initial=0.0), np.max(-down_limit - grades, initial=0.0))
|
||||
)
|
||||
|
||||
def total_violation(x: np.ndarray) -> float:
|
||||
balance = float(np.max(np.abs(equality_matrix @ x - equality_vector), initial=0.0))
|
||||
return max(grade_violation(x), balance)
|
||||
|
||||
# 시·종점 직선과 지반 추종, 두 출발점을 준비한다(전자는 기울기 제약을 자명하게 만족).
|
||||
straight = np.interp(
|
||||
pvi_s[free],
|
||||
[float(pvi_s[0]), float(pvi_s[-1])],
|
||||
[float(base[0]), float(base[-1])],
|
||||
)
|
||||
following = np.interp(pvi_s[free], chainage, ground)
|
||||
attempts = [
|
||||
("trust-constr", straight),
|
||||
("SLSQP", straight),
|
||||
("trust-constr", following),
|
||||
]
|
||||
elevations = base.copy()
|
||||
solution, violation = _best_solution(
|
||||
attempts,
|
||||
objective,
|
||||
objective_jac,
|
||||
hessian,
|
||||
[balance_constraint, grade_constraint],
|
||||
total_violation,
|
||||
)
|
||||
if violation <= BALANCE_TOLERANCE_M:
|
||||
elevations[free] = solution
|
||||
return elevations, True
|
||||
|
||||
# 정확 균형이 기울기 기준과 양립하지 않는 경우: 균형을 벌점으로 완화하되
|
||||
# 법정 기울기 제약은 그대로 강제한 채 불균형이 최소인 해를 찾는다.
|
||||
soft_hessian = hessian + 2.0 * _SOFT_BALANCE_WEIGHT * (equality_matrix.T @ equality_matrix)
|
||||
|
||||
def soft_objective(x: np.ndarray) -> float:
|
||||
gap = equality_matrix @ x - equality_vector
|
||||
return objective(x) + _SOFT_BALANCE_WEIGHT * float(gap @ gap)
|
||||
|
||||
def soft_jac(x: np.ndarray) -> np.ndarray:
|
||||
gap = equality_matrix @ x - equality_vector
|
||||
return objective_jac(x) + 2.0 * _SOFT_BALANCE_WEIGHT * (equality_matrix.T @ gap)
|
||||
|
||||
solution, _ = _best_solution(
|
||||
attempts, soft_objective, soft_jac, soft_hessian, [grade_constraint], grade_violation
|
||||
)
|
||||
elevations[free] = solution
|
||||
return elevations, False
|
||||
|
||||
|
||||
def build_vertical_curves(
|
||||
pvi_s: np.ndarray,
|
||||
pvi_z: np.ndarray,
|
||||
*,
|
||||
radius_m: float,
|
||||
min_curve_length_m: float,
|
||||
skip_delta_pct: float,
|
||||
paved: bool,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""기울기 변화점에 종단곡선(포물선)을 삽입하고 곡선 제원을 만든다."""
|
||||
spans = pvi_s[1:] - pvi_s[:-1]
|
||||
grades = (pvi_z[1:] - pvi_z[:-1]) / spans
|
||||
skip_delta = skip_delta_pct / 100.0
|
||||
curves: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
for index in range(1, len(pvi_s) - 1):
|
||||
grade_in, grade_out = float(grades[index - 1]), float(grades[index])
|
||||
delta = grade_out - grade_in
|
||||
if abs(delta) < 1e-9:
|
||||
continue
|
||||
# 비포장 도로이면서 대수차가 기준 이하이면 법정 예외로 곡선을 두지 않는다.
|
||||
if not paved and abs(delta) <= skip_delta + 1e-12:
|
||||
continue
|
||||
length = radius_m * abs(delta)
|
||||
available = min(float(spans[index - 1]), float(spans[index])) * _CURVE_OVERLAP_RATIO
|
||||
if length > available:
|
||||
length = available
|
||||
warnings.append(
|
||||
f"chainage {pvi_s[index]:.1f}m: 인접 직선이 짧아 종단곡선 길이를 "
|
||||
f"{length:.1f}m로 줄였습니다."
|
||||
)
|
||||
if length < min_curve_length_m - 1e-9:
|
||||
warnings.append(
|
||||
f"chainage {pvi_s[index]:.1f}m: 종단곡선 길이 {length:.1f}m가 "
|
||||
f"기준 {min_curve_length_m:.1f}m에 미달합니다."
|
||||
)
|
||||
if length <= 1e-9:
|
||||
continue
|
||||
curves.append(
|
||||
{
|
||||
"index": index,
|
||||
"chainage_m": float(pvi_s[index]),
|
||||
"elevation_m": float(pvi_z[index]),
|
||||
"grade_in": grade_in,
|
||||
"grade_out": grade_out,
|
||||
"length_m": float(length),
|
||||
"radius_m": float(length / abs(delta)),
|
||||
# 포물선 삽입으로 직선 폴리라인 대비 발생하는 면적 차 (A·L²/24)
|
||||
"area_offset_m2": float(delta * length * length / 24.0),
|
||||
}
|
||||
)
|
||||
return curves, warnings
|
||||
|
||||
|
||||
def evaluate_profile(
|
||||
pvi_s: np.ndarray, pvi_z: np.ndarray, curves: list[dict[str, Any]], targets: np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다."""
|
||||
values = np.interp(targets, pvi_s, pvi_z)
|
||||
for curve in curves:
|
||||
half = curve["length_m"] / 2.0
|
||||
start = curve["chainage_m"] - half
|
||||
end = curve["chainage_m"] + half
|
||||
mask = (targets >= start) & (targets <= end)
|
||||
if not np.any(mask):
|
||||
continue
|
||||
local = targets[mask] - start
|
||||
start_z = curve["elevation_m"] - curve["grade_in"] * half
|
||||
delta = curve["grade_out"] - curve["grade_in"]
|
||||
values[mask] = (
|
||||
start_z + curve["grade_in"] * local + delta / (2.0 * curve["length_m"]) * local * local
|
||||
)
|
||||
return values
|
||||
@@ -487,11 +487,14 @@ def _fillet_alignment(
|
||||
|
||||
|
||||
def resolve_grade_bounds(options: dict[str, Any]) -> dict[str, float]:
|
||||
"""options에서 방향별 경사 상/하한을 해석한다."""
|
||||
"""options에서 방향별 경사 상/하한을 해석한다.
|
||||
|
||||
options의 경사 값 단위는 퍼센트(%)이므로 비율로 환산한다. default는 이미 비율이다.
|
||||
"""
|
||||
|
||||
def _opt(key: str, default: float) -> float:
|
||||
v = options.get(key)
|
||||
return float(v) if v is not None else default
|
||||
return float(v) / 100.0 if v is not None else default
|
||||
|
||||
min_uphill_grade = _opt("min_uphill_grade", ROUTE_ALT_MIN_GRADE)
|
||||
min_downhill_grade = _opt("min_downhill_grade", ROUTE_ALT_MIN_GRADE)
|
||||
|
||||
@@ -7,9 +7,11 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, design_grade_line
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
@@ -17,6 +19,8 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STAGE_SUBDIR = Path("B06_wf3_ProfileCross")
|
||||
_MODELS_SUBDIR = Path("B04_wf1_Surface") / "models"
|
||||
|
||||
@@ -73,6 +77,26 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
|
||||
|
||||
계획선 산출 실패가 종횡단 생성 자체를 무효화하지 않도록 예외를 격리한다.
|
||||
횡단 설계 기반 계획선을 나중에 추가할 수 있게 배열로 보관한다.
|
||||
"""
|
||||
longitudinal.setdefault("design_profiles", [])
|
||||
if grade_options is None:
|
||||
return None
|
||||
try:
|
||||
profile = design_grade_line(longitudinal, grade_options)
|
||||
except (ValueError, KeyError, ArithmeticError):
|
||||
logger.exception("B05 종단 계획선 산출 실패 (종횡단은 유지)")
|
||||
return None
|
||||
longitudinal["design_profiles"].append(profile)
|
||||
return {"id": profile["id"], **profile["summary"]}
|
||||
|
||||
|
||||
def run_section_generation(
|
||||
project_root: Path,
|
||||
route_data_path: str,
|
||||
@@ -81,6 +105,8 @@ def run_section_generation(
|
||||
smooth: bool,
|
||||
*,
|
||||
options: SectionGenerationOptions | None = None,
|
||||
grade_options: GradeDesignOptions | None = None,
|
||||
grade_overrides: dict[str, Any] | None = None,
|
||||
crs: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""종횡단을 생성·저장하고 DB 기록용 데이터를 반환한다.
|
||||
@@ -108,7 +134,8 @@ def run_section_generation(
|
||||
long_dir.mkdir(parents=True, exist_ok=True)
|
||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 종단면 저장
|
||||
# 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다)
|
||||
grade_summary = _append_design_profiles(result["longitudinal"], grade_options)
|
||||
long_file = long_dir / "longitudinal.json"
|
||||
atomic_write_json(long_file, result["longitudinal"])
|
||||
long_summary = {
|
||||
@@ -117,6 +144,14 @@ def run_section_generation(
|
||||
"invalid_samples": result["summary"]["invalid_longitudinal_samples"],
|
||||
# 사용자 선택값의 단일 소스(DB): 재생성·재탐색 시 이 값을 우선 사용한다.
|
||||
"options": result["options"],
|
||||
# 해석이 끝난 기준값은 기록·표시용으로만 보관한다. 이 값을 재계산 시
|
||||
# 폴백으로 되쓰면 등급·지형을 바꿔도 옛 기본값이 법정값을 이겨 갱신되지 않는다.
|
||||
"grade_options": grade_options.as_dict() if grade_options else None,
|
||||
# 재계산 폴백에 쓰는 단일 소스: 사용자가 명시적으로 입력한 값만 담는다.
|
||||
"grade_overrides": {
|
||||
key: value for key, value in (grade_overrides or {}).items() if value is not None
|
||||
},
|
||||
"grade_summary": grade_summary,
|
||||
}
|
||||
|
||||
# 측점별 횡단면 저장 (detail 조회가 폴더 전체를 glob하므로 이전 실행 잔재를 먼저 비운다)
|
||||
|
||||
@@ -371,8 +371,9 @@ def solve_optimal_route(
|
||||
min_curve_radius_m = FOREST_ROAD_MIN_CURVE_R_M.get(grade_class, 12.0)
|
||||
|
||||
def _grade_opt(key: str) -> float:
|
||||
"""options의 경사 값은 퍼센트(%)이므로 비율로 환산한다."""
|
||||
v = options.get(key)
|
||||
return float(v) if (v is not None and float(v) > 0) else max_grade
|
||||
return float(v) / 100.0 if (v is not None and float(v) > 0) else max_grade
|
||||
|
||||
max_uphill_grade = _grade_opt("max_uphill_grade")
|
||||
max_downhill_grade = _grade_opt("max_downhill_grade")
|
||||
@@ -495,6 +496,9 @@ def solve_optimal_route(
|
||||
length_m += h_dist
|
||||
grade_sums += segment_slope * h_dist
|
||||
max_grade_pct = max(max_grade_pct, segment_slope)
|
||||
# 여기서 다루는 z는 **지반고**다. 산지 지형이 급한 것은 위반이 아니므로
|
||||
# 법정 종단기울기가 아니라 탐색 제약(선형을 완만한 지형으로 유도하는 값)과
|
||||
# 대조한다. 법정 기준 준수는 절·성토 후 노면이 되는 계획선이 담당한다.
|
||||
applicable = max_uphill_grade if dz > 0 else max_downhill_grade
|
||||
if dz > 0:
|
||||
max_uphill_pct = max(max_uphill_pct, segment_slope)
|
||||
@@ -646,7 +650,10 @@ def solve_optimal_route(
|
||||
"max_grade_pct": round(max_grade_pct * 100, 2),
|
||||
"max_uphill_pct": round(max_uphill_pct * 100, 2),
|
||||
"max_downhill_pct": round(max_downhill_pct * 100, 2),
|
||||
# 아래 경사 지표는 모두 경로가 지나는 **지반고** 기준이다(노면고 아님).
|
||||
"slope_violations": slope_violations,
|
||||
# slope_violations의 판정 기준 = 탐색 제약. 법정 기준과 혼동하지 않도록 노출한다.
|
||||
"search_max_grade_pct": round(max(max_uphill_grade, max_downhill_grade) * 100, 2),
|
||||
"curve_violations": curve_violations,
|
||||
"min_curve_radius_m": round(min_curve_radius_actual, 2)
|
||||
if math.isfinite(min_curve_radius_actual)
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
@@ -25,16 +26,19 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
insert_route_points,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
GRADE_PERCENT_FIELDS,
|
||||
ContourIntervalUpdateRequest,
|
||||
ContourIntervalUpdateResponse,
|
||||
RouteConfirmResponse,
|
||||
RouteLatestResponse,
|
||||
RouteSolveRequest,
|
||||
RouteSolveResponse,
|
||||
normalize_grade_percent,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
get_latest_grade_options,
|
||||
get_latest_section_options,
|
||||
insert_cross_sections,
|
||||
)
|
||||
@@ -78,6 +82,40 @@ def _section_options(
|
||||
)
|
||||
|
||||
|
||||
def _normalized_route_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""복원 응답의 경사 값을 퍼센트로 맞춘다(과거 비율 저장분 자동 환산)."""
|
||||
if not params:
|
||||
return params
|
||||
options = params.get("options")
|
||||
if not isinstance(options, dict):
|
||||
return params
|
||||
return {
|
||||
**params,
|
||||
"options": {
|
||||
**options,
|
||||
**{
|
||||
key: normalize_grade_percent(options[key])
|
||||
for key in GRADE_PERCENT_FIELDS
|
||||
if key in options
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _grade_options(
|
||||
request: RouteSolveRequest, stored_grade_options: dict[str, Any] | None
|
||||
) -> GradeDesignOptions:
|
||||
"""계획선 기준도 요청 값 → DB 저장 옵션 → config 순으로 결정한다."""
|
||||
return resolve_grade_options(
|
||||
request.grade_class,
|
||||
terrain_type=request.terrain_type,
|
||||
paved=request.paved,
|
||||
main_direction=request.main_direction,
|
||||
requested=request.grade_options(),
|
||||
stored=stored_grade_options,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
|
||||
async def solve_route(
|
||||
project_id: UUID, request: RouteSolveRequest
|
||||
@@ -97,6 +135,7 @@ async def solve_route(
|
||||
"cross_half_width_m": request.cross_half_width_m,
|
||||
"cross_sample_interval_m": request.cross_sample_interval_m,
|
||||
"long_sample_interval_m": request.long_sample_interval_m,
|
||||
**request.grade_options(),
|
||||
}
|
||||
log_b05_debug(
|
||||
logger,
|
||||
@@ -219,11 +258,13 @@ async def solve_route(
|
||||
# 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다.
|
||||
longitudinal_length_m: float | None = None
|
||||
cross_section_count: int | None = None
|
||||
grade_summary: dict[str, Any] | None = None
|
||||
try:
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, request.surface_model_id
|
||||
)
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
stored_grade_options = await get_latest_grade_options(connection, project_id)
|
||||
sections = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
@@ -232,6 +273,10 @@ async def solve_route(
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=_section_options(request, stored_options),
|
||||
grade_options=_grade_options(request, stored_grade_options),
|
||||
# B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로
|
||||
# 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀).
|
||||
grade_overrides=request.grade_options(),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
await connection.begin()
|
||||
@@ -256,6 +301,7 @@ async def solve_route(
|
||||
raise
|
||||
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
|
||||
cross_section_count = len(sections["cross_sections"])
|
||||
grade_summary = sections["longitudinal"]["data"].get("grade_summary")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
|
||||
@@ -272,6 +318,7 @@ async def solve_route(
|
||||
route_data_path=design["route_data_path"],
|
||||
longitudinal_length_m=longitudinal_length_m,
|
||||
cross_section_count=cross_section_count,
|
||||
grade_summary=grade_summary,
|
||||
)
|
||||
except LookupError as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
@@ -349,7 +396,9 @@ async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONRespo
|
||||
route=latest,
|
||||
route_points=route_points,
|
||||
surface_params=surface_params,
|
||||
route_params=route_stage.get("params") if route_stage else None,
|
||||
route_params=_normalized_route_params(
|
||||
route_stage.get("params") if route_stage else None
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 최신 경로 조회 실패: project_id=%s", project_id)
|
||||
|
||||
@@ -4,7 +4,37 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from config.config_system import ROUTE_GRADE_CLASSES
|
||||
from config.config_system import (
|
||||
GRADE_MAIN_DIRECTIONS,
|
||||
GRADE_TERRAIN_TYPES,
|
||||
ROUTE_GRADE_CLASSES,
|
||||
)
|
||||
|
||||
# 경사 입력은 화면·API·DB 모두 **퍼센트(%)** 로 통일한다(비율로 쓰던 과거와 다름).
|
||||
# 엔진 진입점에서만 100으로 나눠 비율로 바꾼다.
|
||||
GRADE_PERCENT_FIELDS = (
|
||||
"max_uphill_grade",
|
||||
"max_downhill_grade",
|
||||
"min_uphill_grade",
|
||||
"min_downhill_grade",
|
||||
)
|
||||
# 과거 버전은 같은 필드를 비율(0.14 = 14%)로 저장했다. 임도 종단기울기를 1% 이하로
|
||||
# 두는 경우는 없으므로, 1 이하 값은 레거시 비율로 보고 퍼센트로 환산한다.
|
||||
LEGACY_GRADE_RATIO_MAX = 1.0
|
||||
|
||||
|
||||
def normalize_grade_percent(value: Any) -> Any:
|
||||
"""경사 값을 퍼센트로 정규화한다(레거시 비율 자동 환산)."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if 0.0 < number <= LEGACY_GRADE_RATIO_MAX:
|
||||
# 0.14 * 100 = 14.000000000000002 같은 잔여 오차를 남기지 않는다.
|
||||
return round(number * 100.0, 6)
|
||||
return number
|
||||
|
||||
|
||||
class RoutePoint(BaseModel):
|
||||
@@ -48,10 +78,11 @@ class RouteSolveRequest(BaseModel):
|
||||
grade_class: str = Field(default="trunk")
|
||||
paved: bool = Field(default=False)
|
||||
min_curve_radius_m: float | None = None
|
||||
max_uphill_grade: float | None = None
|
||||
max_downhill_grade: float | None = None
|
||||
min_uphill_grade: float | None = None
|
||||
min_downhill_grade: float | None = None
|
||||
# 경로탐색 경사 제약 (단위: %). 과거 비율 저장분은 검증 단계에서 자동 환산된다.
|
||||
max_uphill_grade: float | None = Field(default=None, ge=0, le=100)
|
||||
max_downhill_grade: float | None = Field(default=None, ge=0, le=100)
|
||||
min_uphill_grade: float | None = Field(default=None, ge=0, le=100)
|
||||
min_downhill_grade: float | None = Field(default=None, ge=0, le=100)
|
||||
weights: dict[str, float] | None = None
|
||||
allow_avoid_pass_through: bool = Field(default=False)
|
||||
station_interval_m: float | None = Field(default=None, gt=0)
|
||||
@@ -59,14 +90,44 @@ class RouteSolveRequest(BaseModel):
|
||||
cross_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
|
||||
# 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다.
|
||||
terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)")
|
||||
main_direction: str = Field(
|
||||
default="auto", description="주 진행방향 (auto/ascending/descending/none)"
|
||||
)
|
||||
max_grade_pct: float | None = Field(default=None, gt=0)
|
||||
min_vertical_radius_m: float | None = Field(default=None, gt=0)
|
||||
min_tangent_length_m: float | None = Field(default=None, gt=0)
|
||||
balance_segment_length_m: float | None = Field(default=None, gt=0)
|
||||
start_elevation_offset_m: float | None = None
|
||||
end_elevation_offset_m: float | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "RouteSolveRequest":
|
||||
if self.grade_class not in ROUTE_GRADE_CLASSES:
|
||||
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
|
||||
if self.algorithm not in ("dijkstra", "ridge_valley"):
|
||||
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
|
||||
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} 중 하나여야 합니다.")
|
||||
# 화면·DB에 남아 있던 비율 표기를 퍼센트로 맞춰 이후 단계를 단일 단위로 만든다.
|
||||
for field_name in GRADE_PERCENT_FIELDS:
|
||||
setattr(self, field_name, normalize_grade_percent(getattr(self, field_name)))
|
||||
return self
|
||||
|
||||
def grade_options(self) -> dict[str, Any]:
|
||||
"""계획선 엔진에 넘길 요청 측 재정의 값(사용자가 비우면 None)."""
|
||||
return {
|
||||
"max_grade_pct": self.max_grade_pct,
|
||||
"min_vertical_radius_m": self.min_vertical_radius_m,
|
||||
"min_tangent_length_m": self.min_tangent_length_m,
|
||||
"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 points_data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"bp": self.bp.model_dump(),
|
||||
@@ -80,6 +141,8 @@ class RouteSolveRequest(BaseModel):
|
||||
return {
|
||||
"grade_class": self.grade_class,
|
||||
"paved": self.paved,
|
||||
"terrain_type": self.terrain_type,
|
||||
"main_direction": self.main_direction,
|
||||
"min_curve_radius_m": self.min_curve_radius_m,
|
||||
"max_uphill_grade": self.max_uphill_grade,
|
||||
"max_downhill_grade": self.max_downhill_grade,
|
||||
@@ -119,6 +182,8 @@ class RouteSolveResponse(BaseModel):
|
||||
# 종횡단 생성 실패 시 None (경로 자체는 저장됨)
|
||||
longitudinal_length_m: float | None = None
|
||||
cross_section_count: int | None = None
|
||||
# 계획선 산출 실패 시 None (종횡단·경로는 저장됨)
|
||||
grade_summary: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RouteConfirmResponse(BaseModel):
|
||||
|
||||
@@ -167,6 +167,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
stationInterval: next.route_params?.station_interval_m ?? undefined,
|
||||
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
|
||||
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
|
||||
terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined,
|
||||
mainDirection: options.main_direction as RoutePanelValues["mainDirection"] | undefined,
|
||||
maxGradePct: next.route_params?.max_grade_pct ?? undefined,
|
||||
minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined,
|
||||
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
|
||||
balanceSegmentLength: next.route_params?.balance_segment_length_m ?? undefined,
|
||||
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
|
||||
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
|
||||
});
|
||||
viewer.markers.setPoints(restorePoints(next));
|
||||
}
|
||||
@@ -265,11 +273,21 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
cross_half_width_m: null,
|
||||
cross_sample_interval_m: values.crossSampleInterval,
|
||||
long_sample_interval_m: values.longSampleInterval,
|
||||
terrain_type: values.terrainType,
|
||||
main_direction: values.mainDirection,
|
||||
max_grade_pct: values.maxGradePct,
|
||||
min_vertical_radius_m: values.minVerticalRadius,
|
||||
min_tangent_length_m: values.minTangentLength,
|
||||
balance_segment_length_m: values.balanceSegmentLength,
|
||||
start_elevation_offset_m: values.startElevationOffset,
|
||||
end_elevation_offset_m: values.endElevationOffset,
|
||||
});
|
||||
renderLatest(await fetchLatestRoute(activeProjectId));
|
||||
await restoreSections(solved.route_id);
|
||||
if (solved.cross_section_count === null) {
|
||||
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
|
||||
} else if (solved.grade_summary === null) {
|
||||
showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error");
|
||||
} else {
|
||||
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
||||
}
|
||||
|
||||
@@ -16,8 +16,30 @@ export interface RoutePanelValues {
|
||||
stationInterval: number | null;
|
||||
crossSampleInterval: number | null;
|
||||
longSampleInterval: number | null;
|
||||
terrainType: "normal" | "special";
|
||||
mainDirection: "auto" | "ascending" | "descending" | "none";
|
||||
maxGradePct: number | null;
|
||||
minVerticalRadius: number | null;
|
||||
minTangentLength: number | null;
|
||||
balanceSegmentLength: number | null;
|
||||
startElevationOffset: number | null;
|
||||
endElevationOffset: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["gradeClass"],
|
||||
{ speed: number; grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
trunk: { speed: 40, grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
branch: { speed: 30, grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
work: { speed: 20, grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
interface PanelCallbacks {
|
||||
onSolve: () => void;
|
||||
onConfirm: () => void;
|
||||
@@ -195,10 +217,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
gradeLabel.className = "b05-route__field";
|
||||
gradeLabel.append(document.createTextNode("임도 등급"), gradeClass);
|
||||
const minCurveRadius = numberField("최소 곡선반경 (m)");
|
||||
const maxUphillGrade = numberField("오르막 경사 상한");
|
||||
const maxDownhillGrade = numberField("내리막 경사 상한");
|
||||
const minUphillGrade = numberField("오르막 경사 하한");
|
||||
const minDownhillGrade = numberField("내리막 경사 하한");
|
||||
const maxUphillGrade = numberField("오르막 경사 상한 (%)");
|
||||
const maxDownhillGrade = numberField("내리막 경사 상한 (%)");
|
||||
const minUphillGrade = numberField("오르막 경사 하한 (%)");
|
||||
const minDownhillGrade = numberField("내리막 경사 하한 (%)");
|
||||
const paved = checkbox("포장 임도", false);
|
||||
const avoidPass = checkbox("회피구역 통과 허용", false);
|
||||
const details = document.createElement("details");
|
||||
@@ -234,6 +256,71 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
longSampleInterval.wrapper,
|
||||
);
|
||||
|
||||
const gradeLine = section("계획선(시공계획고) 설계");
|
||||
const terrainType = document.createElement("select");
|
||||
terrainType.innerHTML =
|
||||
'<option value="normal">일반지형</option><option value="special">특수지형</option>';
|
||||
const terrainLabel = document.createElement("label");
|
||||
terrainLabel.className = "b05-route__field";
|
||||
terrainLabel.append(document.createTextNode("지형 구분"), terrainType);
|
||||
// 역기울기(5%) 상한을 어느 방향에 적용할지 결정한다. 계곡 횡단·능선 통과처럼
|
||||
// 주 진행방향이 없는 노선에 역기울기를 걸면 지형을 따라갈 수 없어진다.
|
||||
const mainDirection = document.createElement("select");
|
||||
mainDirection.innerHTML =
|
||||
'<option value="auto">자동 판정</option>' +
|
||||
'<option value="ascending">상행 (오르막 노선)</option>' +
|
||||
'<option value="descending">하행 (내리막 노선)</option>' +
|
||||
'<option value="none">역기울기 미적용</option>';
|
||||
const directionLabel = document.createElement("label");
|
||||
directionLabel.className = "b05-route__field";
|
||||
directionLabel.append(document.createTextNode("주 진행방향"), mainDirection);
|
||||
const maxGradePct = numberField("최대 종단기울기 (%)");
|
||||
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
|
||||
const minTangentLength = numberField("최소 직선 길이 (m)");
|
||||
const balanceSegmentLength = numberField("균형 구역 길이 (m, 비우면 전체)");
|
||||
const startElevationOffset = numberField("시점 계획고 조정 (m)");
|
||||
const endElevationOffset = numberField("종점 계획고 조정 (m)");
|
||||
const criteriaNote = document.createElement("p");
|
||||
criteriaNote.className = "b05-route__note";
|
||||
const gradeAdvanced = document.createElement("details");
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
minVerticalRadius.wrapper,
|
||||
minTangentLength.wrapper,
|
||||
startElevationOffset.wrapper,
|
||||
endElevationOffset.wrapper,
|
||||
);
|
||||
const gradeHelp = document.createElement("details");
|
||||
gradeHelp.innerHTML =
|
||||
"<summary>계획선이란?</summary><p>공사 후 노면이 될 높이입니다. 직선과 종단곡선만으로 구성되며, " +
|
||||
"절토량과 성토량이 균형을 이루도록(적분값 0) 자동 산출됩니다.</p>";
|
||||
gradeLine.body.append(
|
||||
terrainLabel,
|
||||
directionLabel,
|
||||
balanceSegmentLength.wrapper,
|
||||
criteriaNote,
|
||||
gradeAdvanced,
|
||||
gradeHelp,
|
||||
);
|
||||
|
||||
/** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
|
||||
function syncCriteria(): void {
|
||||
const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]];
|
||||
const terrain = terrainType.value as RoutePanelValues["terrainType"];
|
||||
maxGradePct.placeholder = String(criteria.grade[terrain]);
|
||||
minVerticalRadius.placeholder = String(criteria.radius);
|
||||
minTangentLength.placeholder = "20";
|
||||
criteriaNote.textContent =
|
||||
`설계속도 ${criteria.speed}km/h 기준 — 종단기울기 ${criteria.grade[terrain]}% 이하, ` +
|
||||
`종단곡선 반경 ${criteria.radius}m 이상. 비워두면 이 기준이 적용됩니다.`;
|
||||
}
|
||||
gradeClass.addEventListener("change", syncCriteria);
|
||||
terrainType.addEventListener("change", syncCriteria);
|
||||
syncCriteria();
|
||||
|
||||
const result = section("경로 설계 산출 결과");
|
||||
const stale = document.createElement("span");
|
||||
stale.className = "b05-route__stale";
|
||||
@@ -264,6 +351,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
stationInterval,
|
||||
crossSampleInterval,
|
||||
longSampleInterval,
|
||||
terrainType,
|
||||
mainDirection,
|
||||
maxGradePct,
|
||||
minVerticalRadius,
|
||||
minTangentLength,
|
||||
balanceSegmentLength,
|
||||
startElevationOffset,
|
||||
endElevationOffset,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
root.append(
|
||||
@@ -272,6 +367,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
selected.root,
|
||||
conditions.root,
|
||||
sectionOptions.root,
|
||||
gradeLine.root,
|
||||
result.root,
|
||||
actionRow,
|
||||
);
|
||||
@@ -294,6 +390,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
stationInterval: parseOptional(stationInterval),
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
mainDirection: mainDirection.value as RoutePanelValues["mainDirection"],
|
||||
maxGradePct: parseOptional(maxGradePct),
|
||||
minVerticalRadius: parseOptional(minVerticalRadius),
|
||||
minTangentLength: parseOptional(minTangentLength),
|
||||
balanceSegmentLength: parseOptional(balanceSegmentLength),
|
||||
startElevationOffset: parseOptional(startElevationOffset),
|
||||
endElevationOffset: parseOptional(endElevationOffset),
|
||||
};
|
||||
},
|
||||
restore(values: Partial<RoutePanelValues>) {
|
||||
@@ -312,6 +416,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
crossSampleInterval.value = String(values.crossSampleInterval);
|
||||
if (values.longSampleInterval != null)
|
||||
longSampleInterval.value = String(values.longSampleInterval);
|
||||
if (values.terrainType) terrainType.value = values.terrainType;
|
||||
if (values.mainDirection) mainDirection.value = values.mainDirection;
|
||||
if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct);
|
||||
if (values.minVerticalRadius != null)
|
||||
minVerticalRadius.value = String(values.minVerticalRadius);
|
||||
if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength);
|
||||
if (values.balanceSegmentLength != null)
|
||||
balanceSegmentLength.value = String(values.balanceSegmentLength);
|
||||
if (values.startElevationOffset != null)
|
||||
startElevationOffset.value = String(values.startElevationOffset);
|
||||
if (values.endElevationOffset != null)
|
||||
endElevationOffset.value = String(values.endElevationOffset);
|
||||
syncCriteria();
|
||||
},
|
||||
setSelected(point: PlacedRoutePoint | null) {
|
||||
selected.root.hidden = !point;
|
||||
|
||||
@@ -11,6 +11,45 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
|
||||
|
||||
const COLLAPSED_KEY = "b05-route-profile-collapsed";
|
||||
const HORIZONTAL_SCROLLBAR_HEIGHT = 16;
|
||||
const BALANCE_BAR_HEIGHT = 24;
|
||||
|
||||
/** 계획선 절·성토 균형 결과를 종단도 위에 한 줄 요약으로 보여준다. */
|
||||
function renderBalanceSummary(data: LongitudinalSection): HTMLElement | null {
|
||||
const profile = data.design_profiles?.[0];
|
||||
if (!profile) return null;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b05-route-profile__balance";
|
||||
const summary = profile.summary;
|
||||
const entries: Array<[string, string, string?]> = [
|
||||
["절토", `${summary.cut_area_m2.toFixed(1)} m²`, "cut"],
|
||||
["성토", `${summary.fill_area_m2.toFixed(1)} m²`, "fill"],
|
||||
["균형오차", `${summary.balance_error_m2.toFixed(2)} m²${summary.balanced ? "" : " (근사)"}`],
|
||||
["최대 종단기울기", `${summary.max_grade_pct.toFixed(2)} %`],
|
||||
["종단곡선", `${summary.vertical_curve_count} 개`],
|
||||
];
|
||||
if (summary.balance_segment_count > 1) {
|
||||
entries.push(["균형구역", `${summary.balance_segment_count} 개`]);
|
||||
}
|
||||
if (summary.suggested_elevation_offset_m !== null) {
|
||||
entries.push(["시·종점 조정 제안", `${summary.suggested_elevation_offset_m.toFixed(1)} m`]);
|
||||
}
|
||||
entries.forEach(([label, value, tone]) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`;
|
||||
const caption = document.createElement("em");
|
||||
caption.textContent = label;
|
||||
item.append(caption, document.createTextNode(value));
|
||||
bar.append(item);
|
||||
});
|
||||
if (summary.warnings.length) {
|
||||
const warning = document.createElement("span");
|
||||
warning.className = "b05-route-profile__balance-warning";
|
||||
warning.textContent = `⚠ ${summary.warnings[0]}`;
|
||||
warning.title = summary.warnings.join("\n");
|
||||
bar.append(warning);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
|
||||
return {
|
||||
@@ -44,24 +83,28 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) =>
|
||||
function draw(): void {
|
||||
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
||||
const availableWidth = Math.max(1, body.clientWidth - 30);
|
||||
const height = Math.max(1, body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT);
|
||||
const balance = renderBalanceSummary(detail.longitudinal);
|
||||
const height = Math.max(
|
||||
1,
|
||||
body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (balance ? BALANCE_BAR_HEIGHT : 0),
|
||||
);
|
||||
const minimumWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval);
|
||||
const width = Math.max(availableWidth, minimumWidth);
|
||||
lastWidth = body.clientWidth;
|
||||
lastHeight = height;
|
||||
body.replaceChildren(
|
||||
createLongitudinalProfile(
|
||||
normalizedLongitudinal(detail.longitudinal),
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
onSelectStation,
|
||||
stationInterval,
|
||||
width,
|
||||
height,
|
||||
minimumWidth,
|
||||
),
|
||||
const chart = createLongitudinalProfile(
|
||||
normalizedLongitudinal(detail.longitudinal),
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
onSelectStation,
|
||||
stationInterval,
|
||||
width,
|
||||
height,
|
||||
minimumWidth,
|
||||
detail.longitudinal.design_profiles ?? [],
|
||||
);
|
||||
body.replaceChildren(...(balance ? [balance, chart] : [chart]));
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
|
||||
@@ -242,3 +242,50 @@
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 계획선 설계 폼 안내문 */
|
||||
.b05-route__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted, var(--color-plum-velvet));
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 종단면도 상단 절·성토 균형 지표 바 */
|
||||
.b05-route-profile__balance {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-16);
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 var(--spacing-8);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
font-size: var(--text-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-item {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-item em {
|
||||
color: var(--color-text-muted, var(--color-plum-velvet));
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-item.is-cut em {
|
||||
color: rgb(220 38 38);
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-item.is-fill em {
|
||||
color: rgb(37 99 235);
|
||||
}
|
||||
|
||||
.b05-route-profile__balance-warning {
|
||||
overflow: hidden;
|
||||
color: rgb(180 83 9);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -64,10 +64,55 @@ export interface SectionStation {
|
||||
frame: { left_xy: [number, number] };
|
||||
}
|
||||
|
||||
/** 계획선 샘플 (계획고와 지반고, 그 차이). */
|
||||
export interface DesignProfileSample {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
ground_elevation_m: number;
|
||||
difference_m: number;
|
||||
}
|
||||
|
||||
/** 절·성토 균형을 판정하는 구역 단위 결과. */
|
||||
export interface DesignProfileSegment {
|
||||
index: number;
|
||||
start_chainage_m: number;
|
||||
end_chainage_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
}
|
||||
|
||||
export interface DesignProfileSummary {
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
max_grade_pct: number;
|
||||
vertical_curve_count: number;
|
||||
pvi_count: number;
|
||||
balance_segment_count: number;
|
||||
balanced: boolean;
|
||||
main_direction: string;
|
||||
suggested_elevation_offset_m: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 계획선. 횡단 설계 기반 계획선이 추가될 수 있어 배열로 전달된다.
|
||||
*/
|
||||
export interface DesignProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
basis: string;
|
||||
samples: DesignProfileSample[];
|
||||
balance_segments: DesignProfileSegment[];
|
||||
summary: DesignProfileSummary;
|
||||
}
|
||||
|
||||
export interface LongitudinalSection {
|
||||
length_m: number;
|
||||
samples: SectionSample[];
|
||||
stations: SectionStation[];
|
||||
design_profiles?: DesignProfile[];
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
|
||||
@@ -86,6 +86,36 @@ async def get_latest_section_options(
|
||||
return options if isinstance(options, dict) else None
|
||||
|
||||
|
||||
async def get_latest_grade_options(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""최신 종단면 data에 저장된 계획선 **사용자 입력값**을 반환한다 (없으면 None).
|
||||
|
||||
해석이 끝난 기준값(`grade_options`)이 아니라 사용자가 명시 입력한 값
|
||||
(`grade_overrides`)만 돌려준다. 전자를 폴백으로 되쓰면 등급·지형 구분을 바꿔도
|
||||
옛 기본값이 법정값을 이겨 갱신되지 않는다.
|
||||
"""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT data
|
||||
FROM longitudinal_sections
|
||||
WHERE project_id = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
data = row[0]
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
overrides = data.get("grade_overrides") if isinstance(data, dict) else None
|
||||
return overrides if isinstance(overrides, dict) else None
|
||||
|
||||
|
||||
async def get_route_generation_source(
|
||||
connection: aiomysql.Connection, project_id: UUID, route_id: int
|
||||
) -> dict[str, Any] | None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type {
|
||||
CrossSection,
|
||||
DesignProfile,
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
SectionSample,
|
||||
@@ -112,6 +113,47 @@ export function longitudinalMinimumWidth(
|
||||
return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다.
|
||||
* 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다.
|
||||
*/
|
||||
function appendCutFillBands(
|
||||
svg: SVGSVGElement,
|
||||
profile: DesignProfile,
|
||||
x: (chainage: number) => number,
|
||||
planY: (index: number) => number,
|
||||
groundY: (index: number) => number,
|
||||
): void {
|
||||
const samples = profile.samples;
|
||||
let start = 0;
|
||||
const flush = (end: number): void => {
|
||||
if (end - start < 1) return;
|
||||
const sign = samples[start].difference_m;
|
||||
if (Math.abs(sign) < 1e-9) return;
|
||||
const top: string[] = [];
|
||||
const bottom: string[] = [];
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
top.push(`${x(samples[index].chainage_m)},${planY(index)}`);
|
||||
bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polygon", {
|
||||
points: [...top, ...bottom].join(" "),
|
||||
class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1].difference_m;
|
||||
const current = samples[index].difference_m;
|
||||
if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) {
|
||||
flush(index);
|
||||
start = index;
|
||||
}
|
||||
}
|
||||
flush(samples.length - 1);
|
||||
}
|
||||
|
||||
export function createLongitudinalProfile(
|
||||
data: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
@@ -122,6 +164,7 @@ export function createLongitudinalProfile(
|
||||
widthPx = LONG_WIDTH,
|
||||
heightPx = LONG_HEIGHT,
|
||||
minimumWidthPx = widthPx,
|
||||
designProfiles: DesignProfile[] = [],
|
||||
): HTMLElement {
|
||||
const samples = data.samples.filter(validElevation);
|
||||
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
|
||||
@@ -141,7 +184,10 @@ export function createLongitudinalProfile(
|
||||
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
|
||||
|
||||
const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
const elevations = samples.map((sample) => sample.elevation_m);
|
||||
// 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다.
|
||||
const elevations = samples
|
||||
.map((sample) => sample.elevation_m)
|
||||
.concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m)));
|
||||
const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations);
|
||||
const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations);
|
||||
const elevationMid = (rawMin + rawMax) / 2;
|
||||
@@ -177,6 +223,32 @@ export function createLongitudinalProfile(
|
||||
);
|
||||
}
|
||||
|
||||
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
|
||||
const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
appendCutFillBands(
|
||||
svg,
|
||||
profile,
|
||||
x,
|
||||
(index) => toY(profile.samples[index].elevation_m),
|
||||
(index) => toY(profile.samples[index].ground_elevation_m),
|
||||
);
|
||||
if (profile.balance_segments.length > 1) {
|
||||
for (const segment of profile.balance_segments.slice(1)) {
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: x(segment.start_chainage_m),
|
||||
y1: LONG_PAD.top,
|
||||
x2: x(segment.start_chainage_m),
|
||||
y2: heightPx - LONG_PAD.bottom,
|
||||
class: "b06-chart__balance-boundary",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const station of data.stations) {
|
||||
const stationX = x(station.chainage_m);
|
||||
const selected = station.station_id === selectedStationId;
|
||||
@@ -221,6 +293,17 @@ export function createLongitudinalProfile(
|
||||
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
|
||||
})
|
||||
.join(" ");
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
svg.append(
|
||||
svgElement("polyline", {
|
||||
points: profile.samples
|
||||
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
|
||||
.join(" "),
|
||||
class: "b06-chart__design-profile",
|
||||
}),
|
||||
);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polyline", { points, class: "b06-chart__profile" }),
|
||||
svgElement("line", {
|
||||
|
||||
@@ -298,6 +298,34 @@
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */
|
||||
.b06-chart__design-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 2.2;
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */
|
||||
.b06-chart__band {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.b06-chart__band--cut {
|
||||
fill: rgb(220 38 38 / 14%);
|
||||
}
|
||||
|
||||
.b06-chart__band--fill {
|
||||
fill: rgb(37 99 235 / 14%);
|
||||
}
|
||||
|
||||
.b06-chart__balance-boundary {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 3 3;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b06-chart__station {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -389,6 +389,17 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const confirmActions = document.createElement("div");
|
||||
confirmActions.className = "b07-drawing-actions";
|
||||
confirmActions.append(confirmButton);
|
||||
|
||||
// TODO: [임시 테스트용] B08 강제 이동 버튼 (테스트 완료 후 제거 예정)
|
||||
const tempB08Btn = createButton({
|
||||
label: "[임시] B08 이동",
|
||||
variant: "outlined",
|
||||
onClick: () => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[5]);
|
||||
},
|
||||
});
|
||||
confirmActions.append(tempB08Btn);
|
||||
|
||||
drawingPanel.append(confirmActions);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,222 @@
|
||||
/* =============================================================================
|
||||
* B08_wf5_Quantity_UI_Page.ts
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출)
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출 UI 메인)
|
||||
*
|
||||
* ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화.
|
||||
* 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용.
|
||||
* 엑셀 내역서(2025년 산불진화임도...) 연동을 위한 전체 145개 템플릿 UI.
|
||||
* 입력데이터(B07 또는 이전 산출값)는 비어있는(null) 상태로 조율되며, 수식 기반 템플릿 항목을 표시합니다.
|
||||
* 오직 B08_wf5_Quantity 폴더 내의 파일만 다룹니다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
INITIAL_QUANTITY_TEMPLATES,
|
||||
QUANTITY_CATEGORY_LABELS,
|
||||
QuantityCategory,
|
||||
QuantityItemTemplate,
|
||||
} from "./B08_wf5_Quantity_Template";
|
||||
import "./B08_wf5_Quantity_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
/** 현재 활성화된 공종 탭 */
|
||||
let currentCategory: QuantityCategory = "earthwork";
|
||||
|
||||
/**
|
||||
* B08 수량 산출 메인 렌더링 함수
|
||||
*/
|
||||
export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
// 1. 공통 Workflow Shell 렌더링
|
||||
await renderPendingWorkflow(root, {
|
||||
title: L("B08_Quantity_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
});
|
||||
|
||||
// 2. 본문 컨테이너 슬롯 확보 (Workflow Shell 내부 본문 영역)
|
||||
const bodyContainer = root.querySelector(".workflow-body") || root;
|
||||
bodyContainer.innerHTML = ""; // 기존 준비중 메시지 대체
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "b08-quantity-container";
|
||||
bodyContainer.appendChild(container);
|
||||
|
||||
// 3. UI 컴포넌트 조합
|
||||
renderSummaryGrid(container);
|
||||
renderTabBar(container);
|
||||
renderTableArea(container);
|
||||
renderActionBar(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 상단 요약 카드 그리드 렌더링 (입력 데이터 펜딩 상태)
|
||||
*/
|
||||
function renderSummaryGrid(parent: HTMLElement): void {
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b08-summary-grid";
|
||||
|
||||
const summaryItems = [
|
||||
{ title: "전체 엑셀 템플릿", val: "총 145개 항목", desc: "6개 세부 공종 100% 매핑 완료" },
|
||||
{ title: "단가산출 / 일위대가", val: "65개 표준공종", desc: "토공 40종 + 일위대가 25종" },
|
||||
{ title: "자재 & 노무비", val: "51개 자재/인력", desc: "자재 37종 + 노무인부 14종" },
|
||||
{ title: "중기 & 일식/폐기물", val: "29개 중기/일식", desc: "중기시간 24종 + 폐기물 5종" },
|
||||
];
|
||||
|
||||
grid.innerHTML = summaryItems
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="b08-card">
|
||||
<div class="b08-card-header">${item.title}</div>
|
||||
<div class="b08-card-value empty-state">${item.val}</div>
|
||||
<div class="b08-card-sub">${item.desc}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
parent.appendChild(grid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 공종 탭 바 렌더링
|
||||
*/
|
||||
function renderTabBar(parent: HTMLElement): void {
|
||||
const tabBar = document.createElement("div");
|
||||
tabBar.className = "b08-tab-bar";
|
||||
|
||||
const categories: QuantityCategory[] = [
|
||||
"earthwork",
|
||||
"structure",
|
||||
"material",
|
||||
"equipment",
|
||||
"waste",
|
||||
"labor",
|
||||
];
|
||||
|
||||
categories.forEach((cat) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `b08-tab-btn ${cat === currentCategory ? "active" : ""}`;
|
||||
btn.textContent = QUANTITY_CATEGORY_LABELS[cat].ko;
|
||||
btn.addEventListener("click", () => {
|
||||
currentCategory = cat;
|
||||
// 탭 업데이트
|
||||
tabBar.querySelectorAll(".b08-tab-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
// 테이블 재렌더링
|
||||
const tableArea = parent.querySelector("#b08-table-area");
|
||||
if (tableArea) {
|
||||
tableArea.innerHTML = "";
|
||||
renderTableContent(tableArea as HTMLElement);
|
||||
}
|
||||
});
|
||||
tabBar.appendChild(btn);
|
||||
});
|
||||
|
||||
parent.appendChild(tabBar);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 수량 집계 데이터 테이블 영역 렌더링
|
||||
*/
|
||||
function renderTableArea(parent: HTMLElement): void {
|
||||
const tableArea = document.createElement("div");
|
||||
tableArea.id = "b08-table-area";
|
||||
tableArea.className = "b08-table-wrapper";
|
||||
parent.appendChild(tableArea);
|
||||
|
||||
renderTableContent(tableArea);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 내용 렌더링 (현재 카테고리 기준)
|
||||
*/
|
||||
function renderTableContent(parent: HTMLElement): void {
|
||||
const items = INITIAL_QUANTITY_TEMPLATES.filter((item) => item.category === currentCategory);
|
||||
|
||||
const table = document.createElement("table");
|
||||
table.className = "b08-table";
|
||||
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">호표</th>
|
||||
<th>명칭</th>
|
||||
<th>규격</th>
|
||||
<th style="width: 60px;">단위</th>
|
||||
<th>설계 산출 수량 (raw_qty)</th>
|
||||
<th style="width: 80px;">할증률</th>
|
||||
<th>최종 산출 수량</th>
|
||||
<th>엑셀 수식 및 산출 근거 (Formula)</th>
|
||||
<th>연동 시트 / 코드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((item) => renderTableRow(item)).join("")}
|
||||
</tbody>
|
||||
`;
|
||||
|
||||
parent.appendChild(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 행 HTML 생성
|
||||
*/
|
||||
function renderTableRow(item: QuantityItemTemplate): string {
|
||||
const rawQtyDisplay =
|
||||
item.raw_qty !== null
|
||||
? item.raw_qty.toLocaleString()
|
||||
: `<span class="b08-empty-val">[입력 대기]</span>`;
|
||||
|
||||
const calcQtyDisplay =
|
||||
item.calc_qty !== null
|
||||
? item.calc_qty.toLocaleString()
|
||||
: `<span class="b08-empty-val">[수식 자동계산]</span>`;
|
||||
|
||||
const codeBadge = item.code_ref
|
||||
? `<span class="b08-badge b08-badge-code">${item.code_ref}</span>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.item_no}</td>
|
||||
<td><strong>${item.name}</strong></td>
|
||||
<td>${item.spec}</td>
|
||||
<td>${item.unit}</td>
|
||||
<td>${rawQtyDisplay}</td>
|
||||
<td>${item.allowance_rate}</td>
|
||||
<td>${calcQtyDisplay}</td>
|
||||
<td><span class="b08-formula-cell">${item.formula_desc}</span></td>
|
||||
<td>
|
||||
<span class="b08-badge b08-badge-sheet">${item.excel_ref_sheet}</span>
|
||||
${codeBadge}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 하단 컨트롤/액션 바 렌더링
|
||||
*/
|
||||
function renderActionBar(parent: HTMLElement): void {
|
||||
const actionBar = document.createElement("div");
|
||||
actionBar.className = "b08-action-bar";
|
||||
|
||||
actionBar.innerHTML = `
|
||||
<div class="b08-info-text">
|
||||
💡 <strong>엑셀 내역서 145개 전체 템플릿 매핑 완료</strong>: 입력 수량값은 차후 이전 워크플로우(B07 등) 및 수식 엔진에 의해 자동 기입됩니다.
|
||||
</div>
|
||||
<button class="b08-btn-confirm">
|
||||
수량 산출 확정 및 B09 견적서 생성 >
|
||||
</button>
|
||||
`;
|
||||
|
||||
const confirmBtn = actionBar.querySelector(".b08-btn-confirm");
|
||||
confirmBtn?.addEventListener("click", () => {
|
||||
alert("B08 수량 템플릿 항목(145종)이 확정되었습니다. B09 견적 파이프라인으로 전달됩니다.");
|
||||
});
|
||||
|
||||
parent.appendChild(actionBar);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/* =============================================================================
|
||||
* B08_wf5_Quantity_UI_Style.css
|
||||
* B08 수량 산출 UI 스타일 정의
|
||||
* ========================================================================== */
|
||||
|
||||
.b08-quantity-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background-color: var(--bg-primary, #1e1e2d);
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
min-height: calc(100vh - 120px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 요약 카드 그리드 */
|
||||
.b08-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.b08-card {
|
||||
background: var(--bg-secondary, #2b2b40);
|
||||
border: 1px solid var(--border-color, #3a3a55);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.b08-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-color, #4e80ee);
|
||||
}
|
||||
|
||||
.b08-card-header {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #9a9ab0);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.b08-card-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-highlight, #38ef7d);
|
||||
}
|
||||
|
||||
.b08-card-value.empty-state {
|
||||
color: #ff9f43;
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.b08-card-sub {
|
||||
font-size: 0.75rem;
|
||||
color: #727290;
|
||||
}
|
||||
|
||||
/* 공종 탭 버튼 영역 */
|
||||
.b08-tab-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 2px solid var(--border-color, #3a3a55);
|
||||
padding-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.b08-tab-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary, #a0a0c0);
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b08-tab-btn:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.b08-tab-btn.active {
|
||||
background: var(--bg-secondary, #2b2b40);
|
||||
border-color: var(--accent-color, #4e80ee) var(--accent-color, #4e80ee) transparent
|
||||
var(--accent-color, #4e80ee);
|
||||
color: var(--accent-color, #4e80ee);
|
||||
}
|
||||
|
||||
/* 수량 집계 데이터 테이블 */
|
||||
.b08-table-wrapper {
|
||||
background: var(--bg-secondary, #2b2b40);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color, #3a3a55);
|
||||
overflow-x: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.b08-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.b08-table th {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-secondary, #b5b5d0);
|
||||
font-weight: 600;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a55);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b08-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #e0e0f0;
|
||||
}
|
||||
|
||||
.b08-table tbody tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.b08-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b08-badge-sheet {
|
||||
background-color: rgba(78, 128, 238, 0.15);
|
||||
color: #4e80ee;
|
||||
border: 1px solid rgba(78, 128, 238, 0.3);
|
||||
}
|
||||
|
||||
.b08-badge-code {
|
||||
background-color: rgba(155, 89, 182, 0.15);
|
||||
color: #af7ac5;
|
||||
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.b08-empty-val {
|
||||
color: #ff9f43;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.b08-formula-cell {
|
||||
font-family: "Fira Code", "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #00cec9;
|
||||
background: rgba(0, 206, 201, 0.05);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 하단 컨트롤 바 */
|
||||
.b08-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary, #2b2b40);
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color, #3a3a55);
|
||||
}
|
||||
|
||||
.b08-info-text {
|
||||
font-size: 0.85rem;
|
||||
color: #9a9ab0;
|
||||
}
|
||||
|
||||
.b08-btn-confirm {
|
||||
background: linear-gradient(135deg, #4e80ee 0%, #38ef7d 100%);
|
||||
color: #000000;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.b08-btn-confirm:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
@@ -244,6 +244,57 @@ SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower()
|
||||
FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2)
|
||||
#
|
||||
# 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준.
|
||||
# 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어
|
||||
# 등급은 grade_to_design_speed로 간접 매핑한다.
|
||||
# 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로
|
||||
# 서로 혼용하지 않는다.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
FOREST_ROAD_PROFILE_CRITERIA = {
|
||||
# 설계속도(km/h)별 법정 기준
|
||||
"design_speed": {
|
||||
40: {
|
||||
"max_grade_pct": {"normal": 7.0, "special": 10.0},
|
||||
"max_reverse_grade_pct": 5.0,
|
||||
"min_vertical_radius_m": 450.0,
|
||||
"min_curve_length_m": 40.0,
|
||||
},
|
||||
30: {
|
||||
"max_grade_pct": {"normal": 8.0, "special": 12.0},
|
||||
"max_reverse_grade_pct": 5.0,
|
||||
"min_vertical_radius_m": 250.0,
|
||||
"min_curve_length_m": 30.0,
|
||||
},
|
||||
20: {
|
||||
"max_grade_pct": {"normal": 9.0, "special": 14.0},
|
||||
"max_reverse_grade_pct": 5.0,
|
||||
"min_vertical_radius_m": 100.0,
|
||||
"min_curve_length_m": 20.0,
|
||||
},
|
||||
},
|
||||
# 임도등급 → 기본 설계속도. 작업임도는 별표에 종단 기준이 없어 20km/h를 준용한다.
|
||||
"grade_to_design_speed": {"trunk": 40, "branch": 30, "work": 20},
|
||||
# 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한
|
||||
"paved_exception_grade_pct": 18.0,
|
||||
# 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다
|
||||
"vertical_curve_skip_delta_pct": 5.0,
|
||||
# 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값)
|
||||
"min_tangent_length_m": 20.0,
|
||||
# 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다)
|
||||
"balance_segment_length_m": None,
|
||||
# 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면
|
||||
# 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다.
|
||||
# V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다.
|
||||
"main_direction_monotone_ratio": 0.5,
|
||||
}
|
||||
GRADE_TERRAIN_TYPES = ("normal", "special")
|
||||
# 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용)
|
||||
GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 6. 저장소 경로
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user