Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -23,6 +23,7 @@ from typing import Any
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import vertical_curve_shortfalls
|
||||||
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA
|
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA
|
||||||
|
|
||||||
ALIGNMENT_SCHEMA_VERSION = 1
|
ALIGNMENT_SCHEMA_VERSION = 1
|
||||||
@@ -69,6 +70,12 @@ class AlignmentPolicy:
|
|||||||
min_plan_radius_m: float = 0.0
|
min_plan_radius_m: float = 0.0
|
||||||
#: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다.
|
#: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다.
|
||||||
hairpin_min_radius_m: float = 0.0
|
hairpin_min_radius_m: float = 0.0
|
||||||
|
#: 설계속도별 종단곡선 최소 R · L(m) — 미달 경고만(432 · 별표2 마.). 0이면 판정 안 함.
|
||||||
|
min_vertical_radius_m: float = 0.0
|
||||||
|
min_curve_length_m: float = 0.0
|
||||||
|
#: 초기값에서 곡선을 두지 않은 꼭짓점 열쇠(432 · `initial_no_curve_keys`) — 손 편집 · R 이면
|
||||||
|
#: 곡선.
|
||||||
|
no_curve_keys: tuple[str, ...] = ()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(
|
def from_config(
|
||||||
@@ -79,6 +86,8 @@ class AlignmentPolicy:
|
|||||||
curve_skip_delta_pct: float,
|
curve_skip_delta_pct: float,
|
||||||
paved: bool,
|
paved: bool,
|
||||||
min_plan_radius_m: float = 0.0,
|
min_plan_radius_m: float = 0.0,
|
||||||
|
min_vertical_radius_m: float = 0.0,
|
||||||
|
min_curve_length_m: float = 0.0,
|
||||||
) -> "AlignmentPolicy":
|
) -> "AlignmentPolicy":
|
||||||
config = FOREST_ROAD_PROFILE_ALIGNMENT
|
config = FOREST_ROAD_PROFILE_ALIGNMENT
|
||||||
return cls(
|
return cls(
|
||||||
@@ -97,6 +106,8 @@ class AlignmentPolicy:
|
|||||||
paved=bool(paved),
|
paved=bool(paved),
|
||||||
min_plan_radius_m=float(min_plan_radius_m),
|
min_plan_radius_m=float(min_plan_radius_m),
|
||||||
hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]),
|
hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]),
|
||||||
|
min_vertical_radius_m=float(min_vertical_radius_m),
|
||||||
|
min_curve_length_m=float(min_curve_length_m),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -134,6 +145,9 @@ class AlignmentPolicy:
|
|||||||
max_grade_pct=float(payload.get("max_grade_pct") or 9.0),
|
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),
|
curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0),
|
||||||
paved=bool(payload.get("paved", False)),
|
paved=bool(payload.get("paved", False)),
|
||||||
|
min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0),
|
||||||
|
min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0),
|
||||||
|
no_curve_keys=tuple(str(key) for key in payload.get("no_curve_keys") or ()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -164,6 +178,9 @@ class AlignmentPolicy:
|
|||||||
# 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06).
|
# 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06).
|
||||||
"min_plan_radius_m": self.min_plan_radius_m,
|
"min_plan_radius_m": self.min_plan_radius_m,
|
||||||
"hairpin_min_radius_m": self.hairpin_min_radius_m,
|
"hairpin_min_radius_m": self.hairpin_min_radius_m,
|
||||||
|
"min_vertical_radius_m": self.min_vertical_radius_m,
|
||||||
|
"min_curve_length_m": self.min_curve_length_m,
|
||||||
|
"no_curve_keys": list(self.no_curve_keys),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -293,6 +310,7 @@ def build_curves(
|
|||||||
curve_radii: dict[str, float] | None = None,
|
curve_radii: dict[str, float] | None = None,
|
||||||
*,
|
*,
|
||||||
only_explicit: bool = False,
|
only_explicit: bool = False,
|
||||||
|
edited: frozenset[str] = frozenset(),
|
||||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||||
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
|
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
|
||||||
|
|
||||||
@@ -321,6 +339,9 @@ def build_curves(
|
|||||||
continue # 직선 판정 한 벌(426)
|
continue # 직선 판정 한 벌(426)
|
||||||
if only_explicit and overrides.get(key) is None:
|
if only_explicit and overrides.get(key) is None:
|
||||||
continue
|
continue
|
||||||
|
# 초기값 직선 꼭짓점(432) — 사용자가 고치거나(`edited`) R 을 넣기 전까지 곡선 없음
|
||||||
|
if key in policy.no_curve_keys and key not in edited and overrides.get(key) is None:
|
||||||
|
continue
|
||||||
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
|
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
|
||||||
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
|
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
|
||||||
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
|
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
|
||||||
@@ -518,8 +539,14 @@ def build_alignment(
|
|||||||
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
|
station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"]
|
||||||
|
|
||||||
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
|
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
|
||||||
|
edited = frozenset(
|
||||||
|
chainage_key(s) for s, source in zip(pvi_s.tolist(), sources) if source == "user"
|
||||||
|
)
|
||||||
curves, warnings = build_curves(
|
curves, warnings = build_curves(
|
||||||
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
|
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves, edited=edited
|
||||||
|
)
|
||||||
|
warnings += vertical_curve_shortfalls(
|
||||||
|
curves, policy.min_vertical_radius_m, policy.min_curve_length_m
|
||||||
)
|
)
|
||||||
segments = _segments(pvi_s, pvi_z)
|
segments = _segments(pvi_s, pvi_z)
|
||||||
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
|
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
|
||||||
|
|||||||
@@ -205,3 +205,52 @@ def carry_edits(
|
|||||||
gaps[slot] = best
|
gaps[slot] = best
|
||||||
out["curve_radii"][slot] = float(value)
|
out["curve_radii"][slot] = float(value)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def carry_no_curve_keys(
|
||||||
|
old_xy: list[tuple[float, float]],
|
||||||
|
new_xy: list[tuple[float, float]],
|
||||||
|
old_keys: list[str],
|
||||||
|
ground_keys: list[str],
|
||||||
|
ground_pvi: list[dict[str, float]],
|
||||||
|
tol_m: float = SAME_LINE_TOL_M,
|
||||||
|
) -> list[str]:
|
||||||
|
"""새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) ·
|
||||||
|
바뀐 자리면 새 지반 첫 계획선 열쇠. 초기값만 · 그 뒤는 사용자 몫(요구 262)."""
|
||||||
|
match = line_matcher(old_xy, new_xy, tol_m)
|
||||||
|
old = [float(key) for key in old_keys]
|
||||||
|
ground = set(ground_keys)
|
||||||
|
out = []
|
||||||
|
for node in ground_pvi:
|
||||||
|
key = chainage_key(float(node["chainage_m"]))
|
||||||
|
s = match(float(node["chainage_m"]))
|
||||||
|
if (s is None and key in ground) or (
|
||||||
|
s is not None and any(abs(s - value) < _ON_NODE_M for value in old)
|
||||||
|
):
|
||||||
|
out.append(key)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def carry_policy(
|
||||||
|
old_xy: list[tuple[float, float]],
|
||||||
|
new_xy: list[tuple[float, float]],
|
||||||
|
old_alignment: dict[str, Any],
|
||||||
|
new_alignment: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""노선 [저장] 정책(432) — 새 정책에 옛 기본 L · 옮긴 직선 열쇠 · 옛 정책 없으면 새 정책."""
|
||||||
|
new_policy = dict(new_alignment.get("policy") or {})
|
||||||
|
old_policy = old_alignment.get("policy") or {}
|
||||||
|
if not old_policy:
|
||||||
|
return new_policy
|
||||||
|
keys = carry_no_curve_keys(
|
||||||
|
old_xy,
|
||||||
|
new_xy,
|
||||||
|
list(old_policy.get("no_curve_keys") or []),
|
||||||
|
list(new_policy.get("no_curve_keys") or []),
|
||||||
|
list(new_alignment.get("base_pvi") or []),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**new_policy,
|
||||||
|
"default_curve_length_m": old_policy.get("default_curve_length_m"),
|
||||||
|
"no_curve_keys": keys,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""B05 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262).
|
||||||
|
|
||||||
|
- `initial_no_curve_keys` : 초기값(자동설계 체인 · 추천 [적용]으로 처음 만드는 종단)에서만 — 기본 L
|
||||||
|
기준 R 이 `initial_straight_radius_m`(500 m)보다 큰(대수차 L/R 안쪽) 꼭짓점 열쇠. 정책
|
||||||
|
`no_curve_keys` 로 저장돼 그 꼭짓점엔 종단곡선을 두지 않음 · 사용자가 그 꼭짓점을 고치거나 R 을
|
||||||
|
넣으면 다시 곡선(`build_curves`). 꼭짓점 높이는 그대로.
|
||||||
|
- `vertical_curve_shortfalls` : 설계속도별 종단곡선 최소 R · L 미달(지식DB 별표2 마.) — 막지 않고
|
||||||
|
경고만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌 · 45 % 규칙으로 줄어든 곡선도 같은 잣대.
|
||||||
|
|
||||||
|
짝: TS `logic/B05_Profile_Profile_CurveLaw.ts` — 거울 시험 `test_432_1_curve_law.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
|
||||||
|
|
||||||
|
# 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값.
|
||||||
|
INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"])
|
||||||
|
|
||||||
|
|
||||||
|
def initial_no_curve_keys(
|
||||||
|
base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것의 열쇠(소수 3자리)."""
|
||||||
|
if not curve_length_m or len(base_s) < 3:
|
||||||
|
return ()
|
||||||
|
limit = float(curve_length_m) / INITIAL_STRAIGHT_RADIUS_M
|
||||||
|
spans = base_s[1:] - base_s[:-1]
|
||||||
|
grades = (base_z[1:] - base_z[:-1]) / np.where(spans > 0, spans, 1.0)
|
||||||
|
keys = []
|
||||||
|
for index in range(1, len(base_s) - 1):
|
||||||
|
if abs(float(grades[index] - grades[index - 1])) < limit:
|
||||||
|
keys.append(f"{float(base_s[index]):.3f}")
|
||||||
|
return tuple(keys)
|
||||||
|
|
||||||
|
|
||||||
|
def vertical_curve_shortfalls(
|
||||||
|
curves: list[dict[str, Any]], min_radius_m: float, min_length_m: float
|
||||||
|
) -> list[str]:
|
||||||
|
"""법정 최소 R · L 을 못 채운 종단곡선 경고 글(누가거리 순) — 기준 0 이면 판정 안 함."""
|
||||||
|
if min_radius_m <= 0 and min_length_m <= 0:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for curve in curves:
|
||||||
|
if curve.get("omitted") or curve.get("skip_allowed"):
|
||||||
|
continue
|
||||||
|
radius = float(curve.get("radius_m", curve.get("r_m", 0.0)))
|
||||||
|
length = float(curve.get("length_m", curve.get("l_m", 0.0)))
|
||||||
|
if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6:
|
||||||
|
out.append(
|
||||||
|
f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — "
|
||||||
|
f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달"
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -45,6 +46,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
|
|||||||
chainage_key,
|
chainage_key,
|
||||||
evaluate,
|
evaluate,
|
||||||
)
|
)
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
||||||
grade_limits,
|
grade_limits,
|
||||||
integration_weights,
|
integration_weights,
|
||||||
@@ -181,7 +183,8 @@ def design_ground_following_profile(
|
|||||||
|
|
||||||
규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원):
|
규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원):
|
||||||
- **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다.
|
- **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다.
|
||||||
- **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는
|
- **변화점마다 종단곡선을 넣는다**(432: 기본 L 기준 R > 500 m 꼭짓점은 빼고 · 정책
|
||||||
|
`no_curve_keys`). 직선과 직선 사이에는 반드시 호가 있고, 호는
|
||||||
변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다.
|
변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다.
|
||||||
- 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가
|
- 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가
|
||||||
되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다
|
되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다
|
||||||
@@ -207,6 +210,9 @@ def design_ground_following_profile(
|
|||||||
paved=options.paved,
|
paved=options.paved,
|
||||||
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
|
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
|
||||||
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
||||||
|
# 종단곡선 최소 R · L — 미달 경고만(432)
|
||||||
|
min_vertical_radius_m=options.min_vertical_radius_m,
|
||||||
|
min_curve_length_m=options.min_curve_length_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
warnings = list(options.warnings)
|
warnings = list(options.warnings)
|
||||||
@@ -234,6 +240,10 @@ def design_ground_following_profile(
|
|||||||
if note:
|
if note:
|
||||||
warnings.append(note)
|
warnings.append(note)
|
||||||
|
|
||||||
|
# 초기값 직선(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 = 지반고 그대로)
|
||||||
|
policy = replace(
|
||||||
|
policy, no_curve_keys=initial_no_curve_keys(base_s, base_z, policy.default_curve_length_m)
|
||||||
|
)
|
||||||
base_z = pass_through(base_s, base_z, policy)
|
base_z = pass_through(base_s, base_z, policy)
|
||||||
|
|
||||||
alignment = build_alignment(
|
alignment = build_alignment(
|
||||||
@@ -289,6 +299,9 @@ def design_pipe_anchored_profile(
|
|||||||
paved=options.paved,
|
paved=options.paved,
|
||||||
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
|
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
|
||||||
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
||||||
|
# 종단곡선 최소 R · L — 미달 경고만(432)
|
||||||
|
min_vertical_radius_m=options.min_vertical_radius_m,
|
||||||
|
min_curve_length_m=options.min_curve_length_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
warnings = list(options.warnings)
|
warnings = list(options.warnings)
|
||||||
@@ -406,6 +419,9 @@ def design_alignment_profile(
|
|||||||
paved=options.paved,
|
paved=options.paved,
|
||||||
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
|
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
|
||||||
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
|
||||||
|
# 종단곡선 최소 R · L — 미달 경고만(432)
|
||||||
|
min_vertical_radius_m=options.min_vertical_radius_m,
|
||||||
|
min_curve_length_m=options.min_curve_length_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
warnings = list(options.warnings)
|
warnings = list(options.warnings)
|
||||||
|
|||||||
@@ -200,7 +200,12 @@ async def _carry_profile(
|
|||||||
new_xy: list[list[float]],
|
new_xy: list[list[float]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
|
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_edits, changed_span
|
from B05_Profile.B05_Profile_Engine_Grade_Carry import (
|
||||||
|
carry_base,
|
||||||
|
carry_edits,
|
||||||
|
carry_policy,
|
||||||
|
changed_span,
|
||||||
|
)
|
||||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||||
from common_util.common_util_json import atomic_write_json
|
from common_util.common_util_json import atomic_write_json
|
||||||
@@ -219,6 +224,7 @@ async def _carry_profile(
|
|||||||
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
|
half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2
|
||||||
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
|
base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"])
|
||||||
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
||||||
|
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
|
||||||
alignment["base_pvi"] = base
|
alignment["base_pvi"] = base
|
||||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import held_pass_through
|
||||||
from common_util.common_util_json import atomic_write_json
|
from common_util.common_util_json import atomic_write_json
|
||||||
|
|
||||||
@@ -367,6 +368,12 @@ def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
|||||||
alignment = longitudinal["profile_alignment"]
|
alignment = longitudinal["profile_alignment"]
|
||||||
base = alignment["base_pvi"]
|
base = alignment["base_pvi"]
|
||||||
base_s = np.array([float(item["chainage_m"]) for item in base])
|
base_s = np.array([float(item["chainage_m"]) for item in base])
|
||||||
|
# 초기값 직선(432) — 균형 종단에서 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이
|
||||||
|
policy = AlignmentPolicy.from_dict(alignment["policy"])
|
||||||
|
keys = initial_no_curve_keys(
|
||||||
|
base_s, np.interp(base_s, stations, plan), policy.default_curve_length_m
|
||||||
|
)
|
||||||
|
alignment["policy"]["no_curve_keys"] = list(keys)
|
||||||
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이
|
# 1차 종단과 같은 맞춤 — 종단곡선을 넣어도 측점마다 균형 종단 계획고를 지나게 · 꼭짓점 사이
|
||||||
# 직선은 균형 종단 상한 안(419-3)
|
# 직선은 균형 종단 상한 안(419-3)
|
||||||
base_z = held_pass_through(
|
base_z = held_pass_through(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
|
import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||||
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
|
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
|
||||||
import { bentEditCount } from "./logic/B05_Profile_Profile_Straight";
|
import { bentEditCount } from "./logic/B05_Profile_Profile_Straight";
|
||||||
|
import { verticalCurveShortfalls } from "./logic/B05_Profile_Profile_CurveLaw";
|
||||||
|
|
||||||
export interface BalanceBarParams {
|
export interface BalanceBarParams {
|
||||||
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
|
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
|
||||||
@@ -98,6 +99,9 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
|||||||
}
|
}
|
||||||
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
|
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
|
||||||
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
|
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]);
|
||||||
|
// 종단곡선 법정 최소 R · L 미달(별표2 마. · 432) — 막지 않고 경고만 · 내역은 툴팁
|
||||||
|
const shortfalls = verticalCurveShortfalls(alignment.curves, policy);
|
||||||
|
if (shortfalls.length) entries.push(["종단곡선 미달", `${shortfalls.length} 곳`, "over"]);
|
||||||
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
|
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
|
||||||
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
|
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
|
||||||
const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426)
|
const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426)
|
||||||
@@ -110,6 +114,7 @@ export function renderBalanceBar(params: BalanceBarParams): void {
|
|||||||
item.append(caption, document.createTextNode(value));
|
item.append(caption, document.createTextNode(value));
|
||||||
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
|
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
|
||||||
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
|
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
|
||||||
|
if (label === "종단곡선 미달") item.title = shortfalls.join("\n");
|
||||||
if (label === "곡선반경 부족" && plan) {
|
if (label === "곡선반경 부족" && plan) {
|
||||||
item.title = [
|
item.title = [
|
||||||
`법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}곳`,
|
`법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}곳`,
|
||||||
|
|||||||
@@ -238,3 +238,27 @@ export function carryEdits(
|
|||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · 바뀐 자리면 새
|
||||||
|
* 지반 첫 계획선 열쇠(파이썬 `carry_no_curve_keys` 짝). 초기값만 · 그 뒤는 사용자 몫(요구 262).
|
||||||
|
*/
|
||||||
|
export function carryNoCurveKeys(
|
||||||
|
oldXy: ReadonlyArray<XY>,
|
||||||
|
newXy: ReadonlyArray<XY>,
|
||||||
|
oldKeys: ReadonlyArray<string>,
|
||||||
|
groundKeys: ReadonlyArray<string>,
|
||||||
|
groundPvi: ReadonlyArray<AlignmentNode>,
|
||||||
|
tolM: number = SAME_LINE_TOL_M,
|
||||||
|
): string[] {
|
||||||
|
const match = lineMatcher(oldXy, newXy, tolM);
|
||||||
|
const old = oldKeys.map(Number);
|
||||||
|
const ground = new Set(groundKeys);
|
||||||
|
return groundPvi
|
||||||
|
.filter((node) => {
|
||||||
|
const s = match(node.chainage_m);
|
||||||
|
if (s === null) return ground.has(node.chainage_m.toFixed(3));
|
||||||
|
return old.some((value) => Math.abs(s - value) < ON_NODE_M);
|
||||||
|
})
|
||||||
|
.map((node) => node.chainage_m.toFixed(3));
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type AlignmentNode,
|
type AlignmentNode,
|
||||||
type AlignmentPolicy,
|
type AlignmentPolicy,
|
||||||
} from "./B05_Profile_UI_Profile_Alignment";
|
} from "./B05_Profile_UI_Profile_Alignment";
|
||||||
|
import { initialNoCurveKeys } from "./B05_Profile_Profile_CurveLaw";
|
||||||
|
|
||||||
/** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */
|
/** 종단 지반 표본 한 점 — 저장 종단 `samples[]` 꼴. */
|
||||||
export interface GroundSample {
|
export interface GroundSample {
|
||||||
@@ -83,6 +84,16 @@ export function passThrough(baseS: number[], target: number[], policy: Alignment
|
|||||||
|
|
||||||
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
/** 첫 계획선 꼭짓점(base_pvi · 0.000001 m) — 못 서는 땅이면 null(서버로 다시 셈). */
|
||||||
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNode[] | null {
|
||||||
|
return groundFollowingPlan(input)?.pvi ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 첫 계획선 꼭짓점 + 초기값 직선 열쇠(432) — 기본 L 기준 R > 500 m 꼭짓점은 곡선 없이(꼭짓점 =
|
||||||
|
* 지반고 그대로) · 파이썬 `design_ground_following_profile` 의 `no_curve_keys` 짝.
|
||||||
|
*/
|
||||||
|
export function groundFollowingPlan(
|
||||||
|
input: GroundFollowingInput,
|
||||||
|
): { pvi: AlignmentNode[]; noCurveKeys: string[] } | null {
|
||||||
const profile = groundProfile(input.samples);
|
const profile = groundProfile(input.samples);
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const { chainage, ground } = profile;
|
const { chainage, ground } = profile;
|
||||||
@@ -103,9 +114,14 @@ export function groundFollowingBasePvi(input: GroundFollowingInput): AlignmentNo
|
|||||||
const target = nodeS.map((s) => interp(chainage, ground, s));
|
const target = nodeS.map((s) => interp(chainage, ground, s));
|
||||||
target[0] += input.startOffsetM ?? 0;
|
target[0] += input.startOffsetM ?? 0;
|
||||||
target[target.length - 1] += input.endOffsetM ?? 0;
|
target[target.length - 1] += input.endOffsetM ?? 0;
|
||||||
const baseZ = passThrough(nodeS, target, input.policy);
|
const noCurveKeys = initialNoCurveKeys(
|
||||||
return nodeS.map((s, index) => ({
|
nodeS.map((s, index) => ({ chainage_m: s, elevation_m: target[index] })),
|
||||||
|
input.policy.default_curve_length_m,
|
||||||
|
);
|
||||||
|
const baseZ = passThrough(nodeS, target, { ...input.policy, no_curve_keys: noCurveKeys });
|
||||||
|
const pvi = nodeS.map((s, index) => ({
|
||||||
chainage_m: roundTo(s, 6),
|
chainage_m: roundTo(s, 6),
|
||||||
elevation_m: roundTo(baseZ[index], 6),
|
elevation_m: roundTo(baseZ[index], 6),
|
||||||
}));
|
}));
|
||||||
|
return { pvi, noCurveKeys };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,16 @@ import {
|
|||||||
type AlignmentBase,
|
type AlignmentBase,
|
||||||
type AlignmentEdits,
|
type AlignmentEdits,
|
||||||
} from "./B05_Profile_UI_Profile_Alignment";
|
} from "./B05_Profile_UI_Profile_Alignment";
|
||||||
import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry";
|
import {
|
||||||
|
carryBase,
|
||||||
|
carryEdits,
|
||||||
|
carryNoCurveKeys,
|
||||||
|
changedSpan,
|
||||||
|
type XY,
|
||||||
|
} from "./B05_Profile_Grade_Carry";
|
||||||
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
||||||
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
import { groundFollowingPlan, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||||
|
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
|
||||||
|
|
||||||
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
|
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
|
||||||
line.map(([x, y]) => ({ x, y }));
|
line.map(([x, y]) => ({ x, y }));
|
||||||
@@ -165,17 +172,30 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
|||||||
}));
|
}));
|
||||||
const profile = groundProfile(samples);
|
const profile = groundProfile(samples);
|
||||||
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
|
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
|
||||||
const groundPvi =
|
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선 열쇠 · 432)
|
||||||
groundFollowingBasePvi({
|
const plan = groundFollowingPlan({
|
||||||
samples,
|
samples,
|
||||||
stations: ground.stations.map((station) => station.chainageM),
|
stations: ground.stations.map((station) => station.chainageM),
|
||||||
policy: saved.policy,
|
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
|
||||||
}) ?? [];
|
});
|
||||||
|
const groundPvi = plan?.pvi ?? [];
|
||||||
const savedLine = deps.savedLine();
|
const savedLine = deps.savedLine();
|
||||||
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
|
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
|
||||||
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
|
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
|
||||||
return {
|
return {
|
||||||
policy: saved.policy,
|
// 이어받은 구간 = 옛 정책(L · 직선 열쇠) · 바뀐 자리 새 꼭짓점만 새 열쇠(서버 `carry_policy` 짝)
|
||||||
|
policy: span
|
||||||
|
? {
|
||||||
|
...saved.policy,
|
||||||
|
no_curve_keys: carryNoCurveKeys(
|
||||||
|
xy(savedLine!),
|
||||||
|
xy(route.line),
|
||||||
|
saved.policy.no_curve_keys ?? [],
|
||||||
|
plan?.noCurveKeys ?? [],
|
||||||
|
groundPvi,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: saved.policy,
|
||||||
onlyExplicitCurves: saved.onlyExplicitCurves,
|
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||||
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
|
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
|
||||||
chainage: profile.chainage,
|
chainage: profile.chainage,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B05_Profile_Profile_CurveLaw.ts
|
||||||
|
* 종단곡선 초기값 직선 · 법정 미달 판정(432 · 요구 262).
|
||||||
|
*
|
||||||
|
* 짝: 파이썬 `B05_Profile_Engine_Grade_CurveLaw.py`(`initial_no_curve_keys` ·
|
||||||
|
* `vertical_curve_shortfalls`) · config `FOREST_ROAD_PROFILE_ALIGNMENT` 의 `default_curve_length_m` ·
|
||||||
|
* `initial_straight_radius_m` — 거울 시험 `test_432_1_curve_law.py`.
|
||||||
|
*
|
||||||
|
* - 초기값에서만 기본 L 기준 R > 500 m(|대수차| < L/500) 꼭짓점은 곡선 없음 — 정책 `no_curve_keys`
|
||||||
|
* 로 얼려 두고, 사용자가 그 꼭짓점을 고치거나(source "user") R 을 넣으면 다시 기본 곡선.
|
||||||
|
* - 법정 최소 R · L 미달은 막지 않고 경고 글만 · 비포장 대수차 5 % 이하(생략 가능)는 뺌.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AlignmentCurve,
|
||||||
|
AlignmentNode,
|
||||||
|
AlignmentPolicy,
|
||||||
|
} from "./B05_Profile_UI_Profile_Alignment";
|
||||||
|
|
||||||
|
/** 기본 종단곡선 길이 L(m) — config `default_curve_length_m` 짝(새 정책에 담겨 옴 · 옛 저장분은 저장값). */
|
||||||
|
export const DEFAULT_CURVE_LENGTH_M = 20;
|
||||||
|
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
|
||||||
|
export const INITIAL_STRAIGHT_RADIUS_M = 500;
|
||||||
|
|
||||||
|
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m 인 것의 열쇠(소수 3자리) — 파이썬 `initial_no_curve_keys`. */
|
||||||
|
export function initialNoCurveKeys(
|
||||||
|
nodes: ReadonlyArray<AlignmentNode>,
|
||||||
|
curveLengthM: number | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!curveLengthM || nodes.length < 3) return [];
|
||||||
|
const limit = curveLengthM / INITIAL_STRAIGHT_RADIUS_M;
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let index = 1; index < nodes.length - 1; index += 1) {
|
||||||
|
const [a, b, c] = [nodes[index - 1], nodes[index], nodes[index + 1]];
|
||||||
|
const spanIn = b.chainage_m - a.chainage_m;
|
||||||
|
const spanOut = c.chainage_m - b.chainage_m;
|
||||||
|
const gradeIn = (b.elevation_m - a.elevation_m) / (spanIn > 0 ? spanIn : 1);
|
||||||
|
const gradeOut = (c.elevation_m - b.elevation_m) / (spanOut > 0 ? spanOut : 1);
|
||||||
|
if (Math.abs(gradeOut - gradeIn) < limit) keys.push(b.chainage_m.toFixed(3));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 초기값 직선 꼭짓점이라 곡선을 안 넣는가 — 사용자가 고친 꼭짓점 · R 지정이면 넣음. */
|
||||||
|
export function skipsInitialCurve(
|
||||||
|
policy: AlignmentPolicy,
|
||||||
|
key: string,
|
||||||
|
node: AlignmentNode & { source?: string },
|
||||||
|
hasRequested: boolean,
|
||||||
|
): boolean {
|
||||||
|
return !hasRequested && node.source !== "user" && (policy.no_curve_keys ?? []).includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 법정 최소 R · L 을 못 채운 종단곡선 경고 글 — 파이썬 `vertical_curve_shortfalls` 와 같은 글. */
|
||||||
|
export function verticalCurveShortfalls(
|
||||||
|
curves: ReadonlyArray<AlignmentCurve>,
|
||||||
|
policy: AlignmentPolicy,
|
||||||
|
): string[] {
|
||||||
|
const minR = policy.min_vertical_radius_m ?? 0;
|
||||||
|
const minL = policy.min_curve_length_m ?? 0;
|
||||||
|
if (minR <= 0 && minL <= 0) return [];
|
||||||
|
return curves
|
||||||
|
.filter((curve) => !curve.omitted && !curve.skip_allowed)
|
||||||
|
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
|
||||||
|
.map(
|
||||||
|
(curve) =>
|
||||||
|
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
|
||||||
|
`법정 최소 R ${minR.toFixed(0)}m · L ${minL.toFixed(0)}m 미달`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
|
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
|
||||||
import { isStraightBend } from "./B05_Profile_Profile_Straight";
|
import { isStraightBend } from "./B05_Profile_Profile_Straight";
|
||||||
|
import { skipsInitialCurve, verticalCurveShortfalls } from "./B05_Profile_Profile_CurveLaw";
|
||||||
|
|
||||||
export interface AlignmentPolicy {
|
export interface AlignmentPolicy {
|
||||||
station_interval_m: number;
|
station_interval_m: number;
|
||||||
@@ -35,6 +36,10 @@ export interface AlignmentPolicy {
|
|||||||
min_plan_radius_m?: number;
|
min_plan_radius_m?: number;
|
||||||
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
|
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
|
||||||
hairpin_min_radius_m?: number;
|
hairpin_min_radius_m?: number;
|
||||||
|
/** 종단곡선 법정 최소 R · L(m) · 초기값 곡선 없는 꼭짓점 열쇠(432 · `_Profile_CurveLaw`). */
|
||||||
|
min_vertical_radius_m?: number;
|
||||||
|
min_curve_length_m?: number;
|
||||||
|
no_curve_keys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlignmentNode {
|
export interface AlignmentNode {
|
||||||
@@ -316,6 +321,7 @@ export function buildCurves(
|
|||||||
const hasRequested = Number.isFinite(requested) && requested > 0;
|
const hasRequested = Number.isFinite(requested) && requested > 0;
|
||||||
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
|
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
|
||||||
if (onlyExplicit && !hasRequested) continue;
|
if (onlyExplicit && !hasRequested) continue;
|
||||||
|
if (skipsInitialCurve(policy, key, pvi[index], hasRequested)) continue; // 초기값 직선(432)
|
||||||
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
|
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
|
||||||
? (policy.default_curve_length_m as number)
|
? (policy.default_curve_length_m as number)
|
||||||
: policy.default_curve_radius_m * Math.abs(delta);
|
: policy.default_curve_radius_m * Math.abs(delta);
|
||||||
@@ -423,6 +429,7 @@ export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): Profi
|
|||||||
grade_percent: length > 0 ? (height / length) * 100 : 0,
|
grade_percent: length > 0 ? (height / length) * 100 : 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
warnings.push(...verticalCurveShortfalls(curves, base.policy)); // 법정 R · L 미달(432)
|
||||||
curves.forEach((curve) => {
|
curves.forEach((curve) => {
|
||||||
curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m);
|
curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m);
|
||||||
curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m);
|
curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m);
|
||||||
|
|||||||
@@ -619,7 +619,11 @@ FOREST_ROAD_PROFILE_ALIGNMENT = {
|
|||||||
# 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만
|
# 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만
|
||||||
# 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시).
|
# 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시).
|
||||||
# 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다.
|
# 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다.
|
||||||
"default_curve_length_m": 15.0,
|
"default_curve_length_m": 20.0,
|
||||||
|
# 초기값(자동설계 체인 · 추천 [적용]으로 처음 만드는 종단)에서만 — 기본 L 기준 R 이 이 값(m)보다
|
||||||
|
# 크면(대수차 L/R 안쪽) 그 꼭짓점에 종단곡선을 두지 않음(직선) · 그 뒤 손 편집은 기본 곡선.
|
||||||
|
# TS 짝 = `B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts` `INITIAL_STRAIGHT_RADIUS_M`.
|
||||||
|
"initial_straight_radius_m": 500.0,
|
||||||
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
|
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
|
||||||
# 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다.
|
# 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다.
|
||||||
"curve_tangent_max_ratio": 0.45,
|
"curve_tangent_max_ratio": 0.45,
|
||||||
|
|||||||
@@ -160,12 +160,14 @@ Promise.resolve(
|
|||||||
|
|
||||||
// ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘)
|
// ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘)
|
||||||
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
||||||
|
const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts");
|
||||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
});
|
});
|
||||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
});
|
});
|
||||||
const gone = [
|
const gone = [
|
||||||
{ id: "pipe-80.17", chainage_m: 80.17 },
|
{ id: "pipe-80.17", chainage_m: 80.17 },
|
||||||
|
|||||||
@@ -21,14 +21,17 @@ function loadTs(file, stubs = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
||||||
|
const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts");
|
||||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
});
|
});
|
||||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
});
|
});
|
||||||
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||||
});
|
});
|
||||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ function loadTs(file, stubs = {}) {
|
|||||||
|
|
||||||
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
|
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
|
||||||
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
||||||
|
const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts");
|
||||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
});
|
});
|
||||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
});
|
});
|
||||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
||||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||||
|
|||||||
@@ -21,20 +21,24 @@ function loadTs(file, stubs = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts");
|
||||||
|
const curveLaw = loadTs("B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts");
|
||||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", {
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
});
|
});
|
||||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||||
"./B05_Profile_Profile_Straight": straightJudge,
|
"./B05_Profile_Profile_Straight": straightJudge,
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
});
|
});
|
||||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
||||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||||
});
|
});
|
||||||
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||||
});
|
});
|
||||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
|
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
|
||||||
|
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||||
"../../common_util/common_util_route_follow": {},
|
"../../common_util/common_util_route_follow": {},
|
||||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||||
"./B05_Profile_Grade_Carry": carry,
|
"./B05_Profile_Grade_Carry": carry,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* 432-1 종단곡선 초기값 직선 · 법정 미달 거울 헬퍼 — `logic/B05_Profile_Grade_Ground.ts`
|
||||||
|
* `groundFollowingPlan` · `_UI_Profile_Alignment.ts` `buildAlignment` · `_Profile_CurveLaw.ts` 를
|
||||||
|
* 그 자리에서 트랜스파일해 돌림. 입력 argv[2] JSON 파일 · 표준출력 JSON 한 벌.
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, "..", "..");
|
||||||
|
const cache = new Map();
|
||||||
|
function loadTs(file) {
|
||||||
|
const full = path.resolve(file);
|
||||||
|
if (cache.has(full)) return cache.get(full);
|
||||||
|
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||||
|
}).outputText;
|
||||||
|
const box = { exports: {} };
|
||||||
|
cache.set(full, box.exports);
|
||||||
|
const localRequire = (request) => {
|
||||||
|
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||||
|
return require(request);
|
||||||
|
};
|
||||||
|
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||||
|
return box.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||||
|
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||||
|
const ground = loadTs(path.join(LOGIC, "B05_Profile_Grade_Ground.ts"));
|
||||||
|
const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts"));
|
||||||
|
const carry = loadTs(path.join(LOGIC, "B05_Profile_Grade_Carry.ts"));
|
||||||
|
|
||||||
|
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||||
|
const plan = ground.groundFollowingPlan(input.ground);
|
||||||
|
const base = {
|
||||||
|
policy: input.policy,
|
||||||
|
onlyExplicitCurves: false,
|
||||||
|
basePvi: input.basePvi,
|
||||||
|
chainage: input.chainage,
|
||||||
|
ground: input.groundZ,
|
||||||
|
stations: input.stations.map((s) => ({ station_id: null, chainage_m: s })),
|
||||||
|
};
|
||||||
|
const cases = input.edits.map((edits) => {
|
||||||
|
const al = align.buildAlignment(base, edits);
|
||||||
|
return {
|
||||||
|
curves: al.curves.map((c) => [c.chainage_m, c.r_m, c.l_m]),
|
||||||
|
shortfalls: law.verticalCurveShortfalls(al.curves, input.policy),
|
||||||
|
warnings: al.warnings,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
pvi: plan ? plan.pvi : null,
|
||||||
|
keys: plan ? plan.noCurveKeys : null,
|
||||||
|
cases,
|
||||||
|
consts: [law.DEFAULT_CURVE_LENGTH_M, law.INITIAL_STRAIGHT_RADIUS_M],
|
||||||
|
carried: input.carry
|
||||||
|
? carry.carryNoCurveKeys(
|
||||||
|
input.carry.old_xy,
|
||||||
|
input.carry.new_xy,
|
||||||
|
input.carry.old_keys,
|
||||||
|
input.carry.ground_keys,
|
||||||
|
input.carry.ground_pvi,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""432-1 종단곡선 기본 L 20 m · 법정 미달 경고 · 초기값 R > 500 m 꼭짓점 = 곡선 없음(요구 262).
|
||||||
|
|
||||||
|
파이썬 `B05_Profile_Engine_Grade_CurveLaw.py` · `design_ground_following_profile` ·
|
||||||
|
`build_alignment`
|
||||||
|
(정책 `no_curve_keys`) ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` · `_Grade_Ground.ts`
|
||||||
|
`groundFollowingPlan` · `_UI_Profile_Alignment.ts` 거울(`helper_432_1_curve_law.cjs`).
|
||||||
|
./venv/Scripts/python.exe -m pytest resources/tester/test_432_1_curve_law.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||||
|
AlignmentPolicy,
|
||||||
|
build_alignment,
|
||||||
|
)
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||||
|
carry_base,
|
||||||
|
carry_no_curve_keys,
|
||||||
|
carry_policy,
|
||||||
|
)
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import initial_no_curve_keys # noqa: E402
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||||
|
design_ground_following_profile,
|
||||||
|
)
|
||||||
|
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT # noqa: E402
|
||||||
|
|
||||||
|
# 20 m 마디 기울기(%) — 꼭짓점 대수차 1 · 6 · 0.5 · 7.5 · 0.5 · 7.5 · 5.5 · 0.5 · 3 %
|
||||||
|
GRADES = [2.0, 3.0, 9.0, 9.5, 2.0, 2.5, 10.0, 4.5, 4.0, 1.0]
|
||||||
|
STATIONS = [20.0 * i for i in range(len(GRADES) + 1)]
|
||||||
|
NO_CURVE = ["20.000", "60.000", "100.000", "160.000", "180.000"]
|
||||||
|
CURVED = [40.0, 80.0, 120.0, 140.0]
|
||||||
|
|
||||||
|
|
||||||
|
def _vertex_z() -> list[float]:
|
||||||
|
z = [100.0]
|
||||||
|
for grade in GRADES:
|
||||||
|
z.append(z[-1] + grade / 100.0 * 20.0)
|
||||||
|
return z
|
||||||
|
|
||||||
|
|
||||||
|
def _longitudinal() -> dict:
|
||||||
|
zs = _vertex_z()
|
||||||
|
samples = [
|
||||||
|
{"chainage_m": float(s), "elevation_m": float(np.interp(s, STATIONS, zs))}
|
||||||
|
for s in range(0, int(STATIONS[-1]) + 1)
|
||||||
|
]
|
||||||
|
return {"samples": samples, "stations": [{"chainage_m": s} for s in STATIONS]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def initial():
|
||||||
|
longitudinal = _longitudinal()
|
||||||
|
options = resolve_grade_options("trunk", terrain_type="normal")
|
||||||
|
alignment, profile = design_ground_following_profile(longitudinal, options)
|
||||||
|
return longitudinal, options, alignment, profile
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild(alignment: dict, edits: dict, policy: dict | None = None) -> dict:
|
||||||
|
longitudinal = _longitudinal()
|
||||||
|
return build_alignment(
|
||||||
|
base_s=np.array([n["chainage_m"] for n in alignment["base_pvi"]]),
|
||||||
|
base_z=np.array([n["elevation_m"] for n in alignment["base_pvi"]]),
|
||||||
|
chainage=np.array([row["chainage_m"] for row in longitudinal["samples"]]),
|
||||||
|
ground=np.array([row["elevation_m"] for row in longitudinal["samples"]]),
|
||||||
|
stations=longitudinal["stations"],
|
||||||
|
policy=AlignmentPolicy.from_dict(policy or alignment["policy"]),
|
||||||
|
edits=edits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
EDITS = [
|
||||||
|
{},
|
||||||
|
{"station_offsets": {"60.000": 0.1}}, # 그 꼭짓점 손 편집 → 기본 곡선
|
||||||
|
{"station_offsets": {"80.000": 0.2}}, # 이웃 손 편집 → 60 · 100 은 얼린 채
|
||||||
|
{"curve_radii": {"100.000": 800.0}}, # R 넣음 → 곡선
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 노선 [저장] — 끝 노드만 옮김(100 m 까지 같은 자리) · 옛 열쇠(추천) · 새 지반 열쇠
|
||||||
|
NEW_TOTAL = 100.0 + float(np.hypot(100.0, 30.0))
|
||||||
|
NEW_STATIONS = [20.0 * i for i in range(11)] + [round(NEW_TOTAL, 3)]
|
||||||
|
CARRY = {
|
||||||
|
"old_xy": [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)],
|
||||||
|
"new_xy": [(0.0, 0.0), (100.0, 0.0), (200.0, 30.0)],
|
||||||
|
"old_keys": NO_CURVE,
|
||||||
|
"ground_keys": ["140.000", "180.000"],
|
||||||
|
"ground_pvi": [{"chainage_m": s, "elevation_m": 0.0} for s in NEW_STATIONS],
|
||||||
|
}
|
||||||
|
# 같은 자리 옛 꼭짓점 열쇠(20 · 60 · 100) + 바뀐 자리 새 지반 열쇠(140 · 180) · 옛 160 은 버림
|
||||||
|
CARRIED = ["20.000", "60.000", "100.000", "140.000", "180.000"]
|
||||||
|
|
||||||
|
|
||||||
|
def _curve_at(alignment: dict) -> list[float]:
|
||||||
|
return [round(c["chainage_m"], 3) for c in alignment["curves"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_기본_L_20_문턱_500_config_TS_짝():
|
||||||
|
assert FOREST_ROAD_PROFILE_ALIGNMENT["default_curve_length_m"] == 20.0
|
||||||
|
assert FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"] == 500.0
|
||||||
|
law = (ROOT / "B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts").read_text(encoding="utf-8")
|
||||||
|
assert "DEFAULT_CURVE_LENGTH_M = 20;" in law and "INITIAL_STRAIGHT_RADIUS_M = 500;" in law
|
||||||
|
|
||||||
|
|
||||||
|
def test_초기값_대수차_4퍼센트_안쪽은_곡선_없음(initial):
|
||||||
|
_long, _options, alignment, _profile = initial
|
||||||
|
assert alignment["policy"]["default_curve_length_m"] == 20.0
|
||||||
|
assert alignment["policy"]["no_curve_keys"] == NO_CURVE
|
||||||
|
assert _curve_at(alignment) == CURVED
|
||||||
|
# 꼭짓점 높이 그대로 — 곡선 없는 측점 계획고 = 지반고(원지반 지남)
|
||||||
|
rows = {round(r["chainage_m"], 3): r for r in alignment["stations"]}
|
||||||
|
for key in NO_CURVE:
|
||||||
|
row = rows[float(key)]
|
||||||
|
assert row["plan_elevation_m"] == pytest.approx(row["ground_elevation_m"], abs=1e-4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_법정_미달_경고_45퍼센트로_줄어든_곳도(initial):
|
||||||
|
_long, options, alignment, _profile = initial
|
||||||
|
assert options.min_vertical_radius_m == 100.0 and options.min_curve_length_m == 20.0
|
||||||
|
assert alignment["policy"]["min_curve_length_m"] == 20.0
|
||||||
|
# 20 m 마디 = 반쪽 9 m 한도 → L 18 m(줄어든 경고 글 그대로) + 법정 L 20 미달 경고
|
||||||
|
lengths = [round(c["l_m"], 3) for c in alignment["curves"]]
|
||||||
|
assert lengths == [18.0] * len(CURVED)
|
||||||
|
reduced = [w for w in alignment["warnings"] if "줄였습니다" in w]
|
||||||
|
short = [w for w in alignment["warnings"] if "미달" in w]
|
||||||
|
assert len(reduced) == len(CURVED) and len(short) == len(CURVED)
|
||||||
|
assert short[0].startswith("40.0m: 종단곡선 R") and "법정 최소 R 100m · L 20m 미달" in short[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_손_편집은_기본_곡선_이웃은_얼린_채(initial):
|
||||||
|
alignment = initial[2]
|
||||||
|
own = _rebuild(alignment, EDITS[1])
|
||||||
|
assert 60.0 in _curve_at(own)
|
||||||
|
neighbor = _rebuild(alignment, EDITS[2])
|
||||||
|
assert 60.0 not in _curve_at(neighbor) and 100.0 not in _curve_at(neighbor)
|
||||||
|
assert 80.0 in _curve_at(neighbor)
|
||||||
|
radius = _rebuild(alignment, EDITS[3])
|
||||||
|
assert 100.0 in _curve_at(radius)
|
||||||
|
|
||||||
|
|
||||||
|
def test_옛_저장분은_그대로(initial):
|
||||||
|
alignment = initial[2]
|
||||||
|
old = {**alignment["policy"], "default_curve_length_m": 15.0}
|
||||||
|
old.pop("no_curve_keys")
|
||||||
|
got = _rebuild(alignment, {}, old)
|
||||||
|
assert len(got["curves"]) == len(STATIONS) - 2 # 꼭짓점마다 곡선(옛 규칙)
|
||||||
|
|
||||||
|
|
||||||
|
def test_TS_거울_첫_계획선_열쇠_곡선_경고(initial, tmp_path):
|
||||||
|
longitudinal, options, alignment, _profile = initial
|
||||||
|
payload = {
|
||||||
|
"ground": {
|
||||||
|
"samples": longitudinal["samples"],
|
||||||
|
"stations": STATIONS,
|
||||||
|
"policy": {**alignment["policy"], "no_curve_keys": []},
|
||||||
|
"startOffsetM": options.start_elevation_offset_m,
|
||||||
|
"endOffsetM": options.end_elevation_offset_m,
|
||||||
|
},
|
||||||
|
"policy": alignment["policy"],
|
||||||
|
"basePvi": alignment["base_pvi"],
|
||||||
|
"chainage": [row["chainage_m"] for row in longitudinal["samples"]],
|
||||||
|
"groundZ": [row["elevation_m"] for row in longitudinal["samples"]],
|
||||||
|
"stations": STATIONS,
|
||||||
|
"edits": [{"station_offsets": {}, "curve_radii": {}, **e} for e in EDITS],
|
||||||
|
"carry": {
|
||||||
|
**CARRY,
|
||||||
|
"old_xy": [{"x": x, "y": y} for x, y in CARRY["old_xy"]],
|
||||||
|
"new_xy": [{"x": x, "y": y} for x, y in CARRY["new_xy"]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
source = tmp_path / "in.json"
|
||||||
|
source.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", os.path.join(HERE, "helper_432_1_curve_law.cjs"), str(source)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
ts = json.loads(proc.stdout)
|
||||||
|
assert ts["consts"] == [20, 500]
|
||||||
|
assert ts["keys"] == NO_CURVE
|
||||||
|
assert [n["chainage_m"] for n in ts["pvi"]] == [n["chainage_m"] for n in alignment["base_pvi"]]
|
||||||
|
assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx(
|
||||||
|
[n["elevation_m"] for n in alignment["base_pvi"]], abs=1e-6
|
||||||
|
)
|
||||||
|
assert ts["carried"] == CARRIED
|
||||||
|
for edits, got in zip(EDITS, ts["cases"], strict=True):
|
||||||
|
py = _rebuild(alignment, edits)
|
||||||
|
assert [c[0] for c in got["curves"]] == pytest.approx(
|
||||||
|
[c["chainage_m"] for c in py["curves"]], abs=1e-6
|
||||||
|
)
|
||||||
|
assert [c[1] for c in got["curves"]] == pytest.approx(
|
||||||
|
[c["r_m"] for c in py["curves"]], rel=1e-6
|
||||||
|
)
|
||||||
|
# 미달 경고 글은 두 쪽 같은 글(옛 경고 글은 앞머리가 원래 다름)
|
||||||
|
py_short = [w for w in py["warnings"] if "미달" in w]
|
||||||
|
assert got["shortfalls"] == py_short
|
||||||
|
assert [w for w in got["warnings"] if "미달" in w] == py_short
|
||||||
|
|
||||||
|
|
||||||
|
def test_추천_적용_균형_종단에서_열쇠(initial, tmp_path):
|
||||||
|
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
|
||||||
|
|
||||||
|
longitudinal, _options, alignment, _profile = initial
|
||||||
|
path = tmp_path / "long.json"
|
||||||
|
path.write_text(json.dumps({**longitudinal, "profile_alignment": alignment}), encoding="utf-8")
|
||||||
|
# 균형 종단 — 100 m 를 2 m 낮춤(그 둘레 대수차가 커짐 · 나머지는 지반 그대로)
|
||||||
|
plan = [z - (2.0 if s == 100.0 else 0.0) for s, z in zip(STATIONS, _vertex_z())]
|
||||||
|
_set_base_pvi(path, STATIONS, plan)
|
||||||
|
stored = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
|
||||||
|
expected = initial_no_curve_keys(np.array(STATIONS), np.array(plan), 20.0)
|
||||||
|
assert stored["no_curve_keys"] == list(expected)
|
||||||
|
assert "100.000" not in stored["no_curve_keys"] and "20.000" in stored["no_curve_keys"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_부르는_곳():
|
||||||
|
def read(rel: str) -> str:
|
||||||
|
return (ROOT / rel).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "skipsInitialCurve(policy, key, pvi[index], hasRequested)" in read(
|
||||||
|
"B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"
|
||||||
|
)
|
||||||
|
assert "verticalCurveShortfalls(alignment.curves, policy)" in read(
|
||||||
|
"B05_Profile/B05_Profile_UI_Profile_Balance.ts"
|
||||||
|
)
|
||||||
|
assert "carryNoCurveKeys(" in read("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts")
|
||||||
|
assert "carry_policy(old_xy, new_xy, old_alignment, alignment)" in read(
|
||||||
|
"B05_Profile/B05_Profile_Route_Save.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_노선_저장_열쇠_옮김():
|
||||||
|
args = [CARRY[k] for k in ("old_xy", "new_xy", "old_keys", "ground_keys", "ground_pvi")]
|
||||||
|
assert carry_no_curve_keys(*args) == CARRIED
|
||||||
|
|
||||||
|
|
||||||
|
def test_추천_적용_종단_노선_고쳐_저장_안_바뀐_구간_그대로(initial, tmp_path):
|
||||||
|
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
|
||||||
|
|
||||||
|
longitudinal, options, alignment, _profile = initial
|
||||||
|
path = tmp_path / "long.json"
|
||||||
|
path.write_text(json.dumps({**longitudinal, "profile_alignment": alignment}), encoding="utf-8")
|
||||||
|
plan = [z - (2.0 if s == 100.0 else 0.0) for s, z in zip(STATIONS, _vertex_z())]
|
||||||
|
_set_base_pvi(path, STATIONS, plan)
|
||||||
|
old = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"]
|
||||||
|
old_rebuilt = _rebuild(old, {})
|
||||||
|
# 새 노선 종단 = 서버가 새로 냄(정책 config · L 20 · 새 지반 열쇠) → 옛 정책 · 옛 꼭짓점 이어받음
|
||||||
|
zs = _vertex_z()
|
||||||
|
samples = [
|
||||||
|
{"chainage_m": float(s), "elevation_m": float(np.interp(s, STATIONS, zs))}
|
||||||
|
for s in range(0, int(NEW_TOTAL) + 1)
|
||||||
|
] + [{"chainage_m": NEW_TOTAL, "elevation_m": float(zs[-1])}]
|
||||||
|
new_long = {"samples": samples, "stations": [{"chainage_m": s} for s in NEW_STATIONS]}
|
||||||
|
fresh, _ = design_ground_following_profile(new_long, options)
|
||||||
|
old_policy = {**old["policy"], "default_curve_length_m": 15.0} # 옛 L 이 이어지는지
|
||||||
|
policy = carry_policy(CARRY["old_xy"], CARRY["new_xy"], {**old, "policy": old_policy}, fresh)
|
||||||
|
assert policy["default_curve_length_m"] == 15.0
|
||||||
|
base = carry_base(CARRY["old_xy"], CARRY["new_xy"], old["base_pvi"], fresh["base_pvi"])
|
||||||
|
carried = build_alignment(
|
||||||
|
base_s=np.array([n["chainage_m"] for n in base]),
|
||||||
|
base_z=np.array([n["elevation_m"] for n in base]),
|
||||||
|
chainage=np.array([row["chainage_m"] for row in samples]),
|
||||||
|
ground=np.array([row["elevation_m"] for row in samples]),
|
||||||
|
stations=new_long["stations"],
|
||||||
|
policy=AlignmentPolicy.from_dict({**policy, "default_curve_length_m": 20.0}),
|
||||||
|
edits={},
|
||||||
|
)
|
||||||
|
before = [(c["chainage_m"], round(c["r_m"], 3)) for c in old_rebuilt["curves"]]
|
||||||
|
after = [(c["chainage_m"], round(c["r_m"], 3)) for c in carried["curves"]]
|
||||||
|
# 안 바뀐 구간(< 80 m · 끝 노드 영향 밖) R · 직선 그대로
|
||||||
|
assert [c for c in after if c[0] < 80] == [c for c in before if c[0] < 80]
|
||||||
|
old_keys = set(old["policy"]["no_curve_keys"])
|
||||||
|
assert {k for k in policy["no_curve_keys"] if float(k) <= 100} == {
|
||||||
|
k for k in old_keys if float(k) <= 100
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user