계획노선 추가

This commit is contained in:
2026-07-20 19:51:20 +09:00
parent 3881dc8509
commit 6781b405d8
22 changed files with 4083 additions and 34 deletions
+31
View File
@@ -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;
}
+559
View File
@@ -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)
+36 -1
View File
@@ -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하므로 이전 실행 잔재를 먼저 비운다)
+8 -1
View File
@@ -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)
+50 -1
View File
@@ -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)
+70 -5
View File
@@ -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):
+18
View File
@@ -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");
}
+121 -4
View File
@@ -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;
+56 -13
View File
@@ -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)}`, "cut"],
["성토", `${summary.fill_area_m2.toFixed(1)}`, "fill"],
["균형오차", `${summary.balance_error_m2.toFixed(2)}${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(() => {
+47
View File
@@ -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
+202 -6
View File
@@ -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);
}
+51
View File
@@ -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.