"""B05 종단 계획선 선형(직선 + 측점 위 종단곡선) 파생 모듈. 계획선을 "폴리라인 샘플"이 아니라 **변화점(PVI) 구조**로 1차 표현하고, 도면 테이블(구배·절토고·성토고·계획고·지반고·누가거리·거리·측점·곡선)에 필요한 값을 전부 여기서 파생시킨다. 폴리라인 샘플은 이 구조에서 만들어지므로 B06/B08은 기존 `design_profiles[].samples` 계약을 그대로 쓴다. 기하 규칙 (사용자 확정 사항): - 변화점은 **기준 측점 위에만** 놓인다 → 곡선 중심이 측점 수직선상에 있다. - 종단곡선은 변화점 대칭 배치이며 좌우에 직선 구간이 반드시 남는다. - 곡선 기본 **반경** R = 측점간격 × `curve_radius_ratio`, 길이 L = R × |A| 로 파생. 수치 최적화(직선 분할 DP·표고 결정)는 [[B05_Profile_Engine_Grade_Solver]]가, 기준 해석과 오케스트레이션은 [[B05_Profile_Engine_Grade]]가 담당한다. 이 모듈은 scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다. """ from dataclasses import dataclass from typing import Any import numpy as np from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA ALIGNMENT_SCHEMA_VERSION = 1 # chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다. _KEY_DECIMALS = 3 def chainage_key(value: float) -> str: """chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙).""" return f"{float(value):.{_KEY_DECIMALS}f}" @dataclass(frozen=True) class AlignmentPolicy: """계획선 선형·편집 정책. config 기본값 위에 노선별 해석값을 얹는다.""" station_interval_m: float curve_radius_ratio: float # 기본 종단곡선 길이 L(m). `None`은 **옛 R 기준 저장분**이라는 뜻이다 — 그 계획선은 # 저장 당시 기하를 그대로 지켜야 하므로 L을 새로 끼워 넣지 않는다. default_curve_length_m: float | None curve_tangent_max_ratio: float curve_skip_legal_exception: bool balance_tolerance_percent: float pvi_penalty_m2: float min_segment_stations: int edit_step_m: float grade_violation_policy: str max_grade_pct: float curve_skip_delta_pct: float paved: bool #: 법정 평면 최소곡선반지름(m) — 위반 표시 기준(2026-09-06). 0이면 판정하지 않는다. min_plan_radius_m: float = 0.0 #: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다. hairpin_min_radius_m: float = 0.0 @classmethod def from_config( cls, *, station_interval_m: float, max_grade_pct: float, curve_skip_delta_pct: float, paved: bool, min_plan_radius_m: float = 0.0, ) -> "AlignmentPolicy": config = FOREST_ROAD_PROFILE_ALIGNMENT return cls( station_interval_m=float(station_interval_m), curve_radius_ratio=float(config["curve_radius_ratio"]), default_curve_length_m=float(config["default_curve_length_m"]), curve_tangent_max_ratio=float(config["curve_tangent_max_ratio"]), curve_skip_legal_exception=bool(config["curve_skip_legal_exception"]), balance_tolerance_percent=float(config["balance_tolerance_percent"]), pvi_penalty_m2=float(config["pvi_penalty_m2"]), min_segment_stations=int(config["min_segment_stations"]), edit_step_m=float(config["edit_step_m"]), grade_violation_policy=str(config["grade_violation_policy"]), max_grade_pct=float(max_grade_pct), curve_skip_delta_pct=float(curve_skip_delta_pct), paved=bool(paved), min_plan_radius_m=float(min_plan_radius_m), hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]), ) @classmethod def from_dict(cls, payload: dict[str, Any]) -> "AlignmentPolicy": """저장된 정책 스냅샷을 복원한다(편집 저장 시 기준을 흔들지 않기 위함).""" config = FOREST_ROAD_PROFILE_ALIGNMENT return cls( station_interval_m=float(payload.get("station_interval_m") or 20.0), curve_radius_ratio=float( payload.get("curve_radius_ratio", config["curve_radius_ratio"]) ), # 옛 스냅샷에는 L이 없다(R 기준 시절). 그때는 저장 당시 기하를 그대로 지킨다 — # 여기서 L을 끼워 넣으면 화면(옛 정책이면 R로 계산)과 서버가 다른 곡선을 낸다. # 새 기준으로 올리려면 계획선을 다시 산출해야 한다. default_curve_length_m=( float(payload["default_curve_length_m"]) if payload.get("default_curve_length_m") else None ), curve_tangent_max_ratio=float( payload.get("curve_tangent_max_ratio", config["curve_tangent_max_ratio"]) ), curve_skip_legal_exception=bool( payload.get("curve_skip_legal_exception", config["curve_skip_legal_exception"]) ), balance_tolerance_percent=float( payload.get("balance_tolerance_percent", config["balance_tolerance_percent"]) ), pvi_penalty_m2=float(config["pvi_penalty_m2"]), min_segment_stations=int(config["min_segment_stations"]), edit_step_m=float(payload.get("edit_step_m", config["edit_step_m"])), grade_violation_policy=str( payload.get("grade_violation_policy", config["grade_violation_policy"]) ), max_grade_pct=float(payload.get("max_grade_pct") or 9.0), curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0), paved=bool(payload.get("paved", False)), ) @property def default_curve_radius_m(self) -> float: """측점간격 기준 기본 종단곡선 반경 — **옛 R 기준 값(호환용)**. 기준은 2026-08-08부터 길이 L이다(`default_curve_length_m`). 이 값은 옛 저장분을 읽는 화면이 계산을 이어갈 수 있도록 정책 스냅샷에 남겨 둘 뿐, 새 곡선 계산은 L을 먼저 본다. """ return max(1e-6, self.station_interval_m * self.curve_radius_ratio) def as_dict(self) -> dict[str, Any]: """프론트엔드가 동일 기하를 재현하는 데 필요한 상수 묶음.""" return { "station_interval_m": self.station_interval_m, "curve_radius_ratio": self.curve_radius_ratio, "default_curve_length_m": self.default_curve_length_m, "curve_tangent_max_ratio": self.curve_tangent_max_ratio, "curve_skip_legal_exception": self.curve_skip_legal_exception, "default_curve_radius_m": self.default_curve_radius_m, "balance_tolerance_percent": self.balance_tolerance_percent, "edit_step_m": self.edit_step_m, "grade_violation_policy": self.grade_violation_policy, "max_grade_pct": self.max_grade_pct, "curve_skip_delta_pct": self.curve_skip_delta_pct, "paved": self.paved, # 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06). "min_plan_radius_m": self.min_plan_radius_m, "hairpin_min_radius_m": self.hairpin_min_radius_m, } def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) -> np.ndarray: """종단곡선을 빼고 변화점 직선만으로 본 표고(탄젠트 표고).""" return np.interp(targets, pvi_s, pvi_z) def resolve_pvi( base_s: np.ndarray, base_z: np.ndarray, station_offsets: dict[str, float], ) -> tuple[np.ndarray, np.ndarray, list[str]]: """자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. 편집 델타는 **자동 선형의 탄젠트 표고 기준**으로 해석한다. 이렇게 해야 다른 측점을 나중에 건드려도 이미 편집한 측점의 표고가 흔들리지 않고, 키를 지우면 자동 선형으로 정확히 돌아온다(원복). """ nodes: dict[float, float] = { round(float(s), _KEY_DECIMALS): float(z) for s, z in zip(base_s, base_z) } sources: dict[float, str] = {key: "auto" for key in nodes} for key, offset in (station_offsets or {}).items(): try: chainage = round(float(key), _KEY_DECIMALS) delta = float(offset) except (TypeError, ValueError): continue if not np.isfinite(chainage) or not np.isfinite(delta): continue if chainage < float(base_s[0]) - 1e-6 or chainage > float(base_s[-1]) + 1e-6: continue base_value = float(np.interp(chainage, base_s, base_z)) nodes[chainage] = base_value + delta sources[chainage] = "user" ordered = sorted(nodes.items()) pvi_s = np.array([item[0] for item in ordered], dtype=np.float64) pvi_z = np.array([item[1] for item in ordered], dtype=np.float64) return pvi_s, pvi_z, [sources[value] for value in pvi_s.tolist()] def build_curves( pvi_s: np.ndarray, pvi_z: np.ndarray, policy: AlignmentPolicy, curve_radii: dict[str, float] | None = None, *, only_explicit: bool = False, ) -> tuple[list[dict[str, Any]], list[str]]: """각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다. **반경 R이 1차 값**이고 곡선길이는 L = R × |대수차| 로 따라온다. 계획고를 편집하면 대수차가 바뀌므로 L은 변하지만 R은 지정한 값 그대로 유지된다. 곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해 좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다). `only_explicit=True` 면 **사용자가 R을 지정한 변화점에만** 곡선을 넣는다. 전체 측점 폴리라인(2026-09-02 사용자 확정)은 모든 측점이 변화점이라 기본 곡선을 다 넣으면 계획고가 지반고에서 떠 버린다 — 라운드는 [직선화]·틸팅으로 사용자가 만들 때만 생긴다. """ overrides = curve_radii or {} spans = pvi_s[1:] - pvi_s[:-1] grades = (pvi_z[1:] - pvi_z[:-1]) / np.where(spans > 0, spans, 1.0) curves: list[dict[str, Any]] = [] warnings: list[str] = [] skip_delta = policy.curve_skip_delta_pct / 100.0 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 chainage = float(pvi_s[index]) key = chainage_key(chainage) if abs(delta) < 1e-9: continue if only_explicit and overrides.get(key) is None: continue # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. # 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을 # 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능). skip_allowed = not policy.paved and abs(delta) <= skip_delta + 1e-12 omitted = skip_allowed and policy.curve_skip_legal_exception # 기준은 길이 L이다(2026-08-08 사용자 확정). 사용자가 이 변화점 R을 직접 지정했으면 # 그쪽이 이기고, 아니면 기본 L을 그대로 쓴다 — 대수차가 작아도 호가 사라지지 않는다. requested = overrides.get(key) try: radius = float(requested) if requested is not None else None except (TypeError, ValueError): radius = None if radius is not None and (not np.isfinite(radius) or radius <= 0): radius = None if radius is not None: length = radius * abs(delta) elif policy.default_curve_length_m: length = policy.default_curve_length_m else: # L이 없는 옛 저장분 — 그 시절 기준(R)으로 계산해 저장 당시 기하를 지킨다. length = policy.default_curve_radius_m * abs(delta) half_limit = ( min(float(spans[index - 1]), float(spans[index])) * policy.curve_tangent_max_ratio ) # 인접 직선이 짧으면 넣을 수 있는 **최대 L**까지만 줄인다 — 0으로 죽이지 않는다. half = min(length / 2.0, half_limit) if half <= 1e-9: continue if length / 2.0 - half > 1e-6: warnings.append( f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 " f"{half * 2.0:.1f}m(반경 {half * 2.0 / abs(delta):.0f}m)로 줄였습니다." ) length = half * 2.0 curves.append( { "pvi_index": index, "chainage_m": chainage, "elevation_m": float(pvi_z[index]), "grade_in": grade_in, "grade_out": grade_out, "delta_pct": delta * 100.0, "bvc_m": chainage - half, "evc_m": chainage + half, "length_m": length, "radius_m": length / abs(delta), # K = 곡선길이 / 기울기 대수차(%) — 도면 주기 표기와 동일 정의 "k": length / (abs(delta) * 100.0), # 중앙종거: 변화점에서 탄젠트와 곡선의 수직 거리 (|A|·L/8) "middle_ordinate_m": abs(delta) * length / 8.0, # 포물선 삽입으로 직선 폴리라인 대비 발생하는 면적 차 (A·L²/24) "area_offset_m2": delta * length * length / 24.0, "omitted": bool(omitted), "skip_allowed": bool(skip_allowed), "omit_reason": ( f"비포장 대수차 {policy.curve_skip_delta_pct:.0f}% 이하 (법정 생략 가능)" if skip_allowed else None ), } ) return curves, warnings def evaluate( 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: if curve["omitted"]: continue start, end = curve["bvc_m"], curve["evc_m"] mask = (targets >= start) & (targets <= end) if not np.any(mask): continue local = targets[mask] - start half = curve["length_m"] / 2.0 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 def _trapezoid_weights(chainage: np.ndarray) -> np.ndarray: """사다리꼴 적분용 샘플 가중치(ds).""" weights = np.zeros_like(chainage) if len(chainage) < 2: return weights 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 _segments(pvi_s: np.ndarray, pvi_z: np.ndarray) -> list[dict[str, Any]]: """변화점 사이 직선 구간의 연장·고저차·구배(%).""" rows: list[dict[str, Any]] = [] for index in range(len(pvi_s) - 1): length = float(pvi_s[index + 1] - pvi_s[index]) height = float(pvi_z[index + 1] - pvi_z[index]) rows.append( { "index": index, "from_m": round(float(pvi_s[index]), 6), "to_m": round(float(pvi_s[index + 1]), 6), "length_m": round(length, 6), "height_m": round(height, 6), "grade_percent": round(height / length * 100.0 if length > 0 else 0.0, 6), } ) return rows def _station_rows( stations: list[dict[str, Any]], pvi_s: np.ndarray, pvi_z: np.ndarray, curves: list[dict[str, Any]], chainage: np.ndarray, ground: np.ndarray, ) -> list[dict[str, Any]]: """도면 테이블 한 열에 해당하는 측점별 값 (거리·누가거리·지반고·계획고·절성토고).""" if not stations: return [] targets = np.array([float(item["chainage_m"]) for item in stations], dtype=np.float64) plan = evaluate(pvi_s, pvi_z, curves, targets) natural = np.interp(targets, chainage, ground) rows: list[dict[str, Any]] = [] for index, station in enumerate(stations): difference = float(natural[index] - plan[index]) rows.append( { "station_id": station.get("station_id"), "chainage_m": round(float(targets[index]), 6), "distance_m": round( float(targets[index] - targets[index - 1]) if index else 0.0, 6 ), "ground_elevation_m": round(float(natural[index]), 6), "plan_elevation_m": round(float(plan[index]), 6), "cut_m": round(max(difference, 0.0), 6), "fill_m": round(max(-difference, 0.0), 6), } ) return rows def build_alignment( *, base_s: np.ndarray, base_z: np.ndarray, chainage: np.ndarray, ground: np.ndarray, stations: list[dict[str, Any]], policy: AlignmentPolicy, edits: dict[str, Any] | None = None, only_explicit_curves: bool = False, ) -> dict[str, Any]: """자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다. `only_explicit_curves=True` 는 전체 측점 폴리라인용 — 사용자가 R을 지정한 변화점에만 종단곡선을 넣는다(`build_curves(only_explicit=...)` 와 같은 뜻). """ edits = edits or {} station_offsets = { str(key): float(value) for key, value in (edits.get("station_offsets") or {}).items() if value is not None } curve_radii = { str(key): float(value) for key, value in (edits.get("curve_radii") or {}).items() if value is not None } pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) curves, warnings = build_curves( pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves ) segments = _segments(pvi_s, pvi_z) # 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면 # 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를 # 정본 계획선이 잘라먹어, 종단곡선 R이 보이지 않고 B06 계획고·B08 CAD 계획선이 그 # 지점에서 어긋난다(2026-08-03 사용자 보고). 프론트(buildAlignment)와 같은 규칙이다. extra_points = [pvi_s] + [ np.array([curve["bvc_m"], curve["chainage_m"], curve["evc_m"]]) for curve in curves ] merged = np.concatenate([chainage, *extra_points]) merged = merged[(merged >= chainage[0] - 1e-6) & (merged <= chainage[-1] + 1e-6)] sample_s = np.unique(np.round(merged, 6)) sample_ground = np.interp(sample_s, chainage, ground) plan = evaluate(pvi_s, pvi_z, curves, sample_s) difference = plan - sample_ground weights = _trapezoid_weights(sample_s) cut_area = float(weights[difference < 0] @ -difference[difference < 0]) fill_area = float(weights[difference > 0] @ difference[difference > 0]) reference = max(cut_area, fill_area) imbalance = abs(cut_area - fill_area) / reference * 100.0 if reference > 1e-9 else 0.0 curve_by_pvi = {curve["pvi_index"]: curve for curve in curves} pvi_rows: list[dict[str, Any]] = [] for index in range(len(pvi_s)): curve = curve_by_pvi.get(index) pvi_rows.append( { "chainage_m": round(float(pvi_s[index]), 6), "elevation_m": round(float(pvi_z[index]), 6), "source": sources[index], "kind": "bp" if index == 0 else "ep" if index == len(pvi_s) - 1 else "pvi", "grade_in_pct": round(segments[index - 1]["grade_percent"], 6) if index else None, "grade_out_pct": ( round(segments[index]["grade_percent"], 6) if index < len(segments) else None ), "curve_l_m": round(float(curve["length_m"]), 6) if curve else None, "curve_r_m": round(float(curve["radius_m"]), 6) if curve else None, } ) violations = [ { "segment_index": segment["index"], "type": "grade_over", "value": segment["grade_percent"], "limit": policy.max_grade_pct, } for segment in segments if abs(segment["grade_percent"]) > policy.max_grade_pct + 1e-6 ] if violations: worst = max(abs(item["value"]) for item in violations) warnings.append( f"종단기울기 {worst:.2f}%가 기준 {policy.max_grade_pct:.2f}%를 초과하는 구간이 " f"{len(violations)}개 있습니다." ) within_tolerance = imbalance <= policy.balance_tolerance_percent + 1e-9 if not within_tolerance: warnings.append( f"절·성토 불균형 {imbalance:.1f}%가 허용치 " f"{policy.balance_tolerance_percent:.1f}%를 초과합니다." ) return { "schema_version": ALIGNMENT_SCHEMA_VERSION, "policy": policy.as_dict(), # 화면 사본(`B05_Profile_UI_Profile_Alignment.ts`)이 같은 규칙으로 다시 그리려면 # 이 값이 저장본에 남아 있어야 한다(2026-09-02 전체 측점 폴리라인). "only_explicit_curves": bool(only_explicit_curves), "base_pvi": [ {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} for s, z in zip(base_s, base_z) ], "edits": {"station_offsets": station_offsets, "curve_radii": curve_radii}, "pvi": pvi_rows, "segments": segments, "curves": [ { "pvi_index": curve["pvi_index"], "chainage_m": round(float(curve["chainage_m"]), 6), "bvc_m": round(float(curve["bvc_m"]), 6), "evc_m": round(float(curve["evc_m"]), 6), "bvc_elevation_m": round( float(evaluate(pvi_s, pvi_z, curves, np.array([curve["bvc_m"]]))[0]), 6 ), "evc_elevation_m": round( float(evaluate(pvi_s, pvi_z, curves, np.array([curve["evc_m"]]))[0]), 6 ), "l_m": round(float(curve["length_m"]), 6), "r_m": round(float(curve["radius_m"]), 6), "k": round(float(curve["k"]), 6), "delta_pct": round(float(curve["delta_pct"]), 6), "middle_ordinate_m": round(float(curve["middle_ordinate_m"]), 6), "omitted": curve["omitted"], "skip_allowed": curve["skip_allowed"], "omit_reason": curve["omit_reason"], } for curve in curves ], "stations": _station_rows(stations, pvi_s, pvi_z, curves, chainage, ground), "samples": [ { "chainage_m": round(float(sample_s[index]), 6), "elevation_m": round(float(plan[index]), 6), "ground_elevation_m": round(float(sample_ground[index]), 6), "difference_m": round(float(difference[index]), 6), } for index in range(len(sample_s)) ], "balance": { "cut_area_m2": round(cut_area, 6), "fill_area_m2": round(fill_area, 6), "net_area_m2": round(fill_area - cut_area, 6), "imbalance_percent": round(imbalance, 6), "tolerance_percent": policy.balance_tolerance_percent, "within_tolerance": bool(within_tolerance), }, "violations": violations, "warnings": warnings, }