Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -280,6 +280,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
base: () => base,
|
||||
alignment: () => alignment,
|
||||
edits: () => store.edits(),
|
||||
savedRadii: () => store.savedRadii(),
|
||||
applyEdits: (next) => applyEdits(next),
|
||||
selectedStationId: () => selectedStationId,
|
||||
irregularStations: () => irregularStations,
|
||||
|
||||
@@ -44,7 +44,7 @@ export function listedBase(
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat),
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -367,11 +368,13 @@ def design_pipe_anchored_profile(
|
||||
# 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다.
|
||||
# 시·종점만 오프셋을 얹는다.
|
||||
target = np.interp(base_s, chainage, ground)
|
||||
lifted = np.zeros(len(base_s), dtype=bool)
|
||||
if pipe_clearances:
|
||||
for index, value in enumerate(base_s):
|
||||
clearance = _clearance_at(pipe_clearances, float(value))
|
||||
if clearance > 0:
|
||||
target[index] += clearance
|
||||
lifted[index] = True
|
||||
target[0] = fixed[0]
|
||||
target[-1] = fixed[1]
|
||||
|
||||
@@ -380,12 +383,15 @@ def design_pipe_anchored_profile(
|
||||
edits = dict(edits or {})
|
||||
user_radii = dict(edits.get("curve_radii") or {})
|
||||
|
||||
def radii_for(nodes_z: np.ndarray) -> dict[str, float]:
|
||||
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다)."""
|
||||
def radii_for(nodes_z: np.ndarray, held: np.ndarray) -> dict[str, float]:
|
||||
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다) ·
|
||||
직선 위로 옮긴(`held`) 꼭짓점은 R 없음."""
|
||||
spans = base_s[1:] - base_s[:-1]
|
||||
grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0)
|
||||
resolved: dict[str, float] = {}
|
||||
for index in range(1, len(base_s) - 1):
|
||||
if held[index]:
|
||||
continue
|
||||
delta = abs(float(grades[index] - grades[index - 1]))
|
||||
radius = target_length / delta if delta > 1e-9 else min_radius
|
||||
resolved[chainage_key(base_s[index])] = max(radius, min_radius)
|
||||
@@ -394,19 +400,28 @@ def design_pipe_anchored_profile(
|
||||
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
|
||||
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
|
||||
# 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다.
|
||||
base_z = target.copy()
|
||||
radii = radii_for(base_z)
|
||||
for _ in range(12):
|
||||
radii = radii_for(base_z)
|
||||
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
|
||||
plan_at = evaluate(base_s, base_z, curves, base_s)
|
||||
error = target - plan_at
|
||||
error[0] = 0.0
|
||||
error[-1] = 0.0
|
||||
if float(np.max(np.abs(error))) < 1e-4:
|
||||
# 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 관 꼭짓점 묶음은 앞뒤 꺾임을 잇는
|
||||
# 직선 위(높이 한도 없음 · 요구 269-2) · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시 ·
|
||||
# 시설 여유로 들린 관 꼭짓점은 그대로(직선화 경계).
|
||||
held = np.zeros(len(base_s), dtype=bool)
|
||||
for _ in range(len(base_s)):
|
||||
base_z = straighten_held(base_s, target, held)
|
||||
for _ in range(12):
|
||||
radii = radii_for(base_z, held)
|
||||
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
|
||||
plan_at = evaluate(base_s, base_z, curves, base_s)
|
||||
error = target - plan_at
|
||||
error[0] = 0.0
|
||||
error[-1] = 0.0
|
||||
error[held] = 0.0
|
||||
if float(np.max(np.abs(error))) < 1e-4:
|
||||
break
|
||||
base_z = straighten_held(base_s, base_z + error, held)
|
||||
flat = flat_bends(base_s, base_z, policy.default_curve_length_m) & ~held & ~lifted
|
||||
if not flat.any():
|
||||
break
|
||||
base_z = base_z + error
|
||||
edits["curve_radii"] = radii
|
||||
held = held | flat
|
||||
edits["curve_radii"] = radii_for(base_z, held)
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
@@ -498,6 +513,14 @@ def design_alignment_profile(
|
||||
down_limit,
|
||||
balance_tolerance_percent=policy.balance_tolerance_percent,
|
||||
)
|
||||
# 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위
|
||||
held = np.zeros(len(base_s), dtype=bool)
|
||||
flat = flat_bends(base_s, base_z, policy.default_curve_length_m)
|
||||
while flat.any():
|
||||
held = held | flat
|
||||
straight = straighten_held(base_s, base_z, held)
|
||||
flat = flat_bends(base_s, straight, policy.default_curve_length_m) & ~held
|
||||
base_z = straighten_held(base_s, base_z, held)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
@@ -531,6 +554,16 @@ def rebuild_alignment_profile(
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
criteria = previous.get("criteria") or {}
|
||||
# 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로
|
||||
# 채움(433-13 · TS `readAlignment` 짝)
|
||||
policy = replace(
|
||||
policy,
|
||||
min_vertical_radius_m=policy.min_vertical_radius_m
|
||||
or float(criteria.get("min_vertical_radius_m") or 0.0),
|
||||
min_curve_length_m=policy.min_curve_length_m
|
||||
or float(criteria.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
@@ -540,7 +573,6 @@ def rebuild_alignment_profile(
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
)
|
||||
criteria = previous.get("criteria") or {}
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
||||
max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0),
|
||||
|
||||
@@ -213,14 +213,21 @@ async def sync_uphill_overrides_into_designs(
|
||||
_SAME_STATION_M = 0.006
|
||||
#: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438).
|
||||
_SEAT_GROUND_M = 0.01
|
||||
#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5).
|
||||
_SEAT_KEEP_M = 0.001
|
||||
|
||||
|
||||
def _seat_born_nodes(
|
||||
stored: dict[str, Any], base: list[dict[str, Any]], born: list[float]
|
||||
stored: dict[str, Any],
|
||||
base: list[dict[str, Any]],
|
||||
born: list[float],
|
||||
held: list[float] = (),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427).
|
||||
|
||||
다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
|
||||
이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 ·
|
||||
바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그
|
||||
밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
|
||||
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점
|
||||
대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 =
|
||||
`seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 =
|
||||
@@ -258,20 +265,41 @@ def _seat_born_nodes(
|
||||
key=lambda row: row[0],
|
||||
)
|
||||
base_s = np.array([row[0] for row in rows], dtype=np.float64)
|
||||
target = np.array([row[1] for row in rows], dtype=np.float64)
|
||||
free = np.array([row[2] is None for row in rows])
|
||||
base_z = target.copy()
|
||||
for _ in range(20):
|
||||
born_rows = np.array([row[2] is None for row in rows])
|
||||
# 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것
|
||||
target = np.where(
|
||||
born_rows,
|
||||
np.array([row[1] for row in rows], dtype=np.float64),
|
||||
evaluate(pre_s, pre_z, pre_curves, base_s),
|
||||
)
|
||||
can = np.array(
|
||||
[
|
||||
row[2] is not None and all(abs(row[0] - value) >= _SAME_STATION_M for value in held)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
free = born_rows.copy()
|
||||
base_z = np.array([row[1] for row in rows], dtype=np.float64)
|
||||
for _ in range(len(rows)):
|
||||
for _ in range(20):
|
||||
curves, _warnings = build_curves(base_s, base_z, policy)
|
||||
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
|
||||
if float(np.max(np.abs(error))) < 1e-6:
|
||||
break
|
||||
base_z = base_z + error
|
||||
# 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼)
|
||||
curves, _warnings = build_curves(base_s, base_z, policy)
|
||||
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
|
||||
if float(np.max(np.abs(error))) < 1e-6:
|
||||
drift = (
|
||||
can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M)
|
||||
)
|
||||
if not drift.any():
|
||||
break
|
||||
base_z = base_z + error
|
||||
free |= drift
|
||||
return [
|
||||
node
|
||||
if node is not None
|
||||
else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for (s, _target, node), z in zip(rows, base_z.tolist())
|
||||
if node is not None and not keep
|
||||
else {**(node or {}), "chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for (s, _z, node), z, keep in zip(rows, base_z.tolist(), free.tolist())
|
||||
]
|
||||
|
||||
|
||||
@@ -367,7 +395,8 @@ def _follow_moved_stations(
|
||||
changed = True
|
||||
if new_key is not None and new_key not in reseat:
|
||||
base.append({**node, "chainage_m": float(new_key)})
|
||||
seated = _seat_born_nodes(stored, base, seat)
|
||||
held = [float(key) for key in next_edits["station_offsets"]]
|
||||
seated = _seat_born_nodes(stored, base, seat, held)
|
||||
changed = changed or len(seated) != len(base)
|
||||
base = seated
|
||||
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
|
||||
|
||||
@@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): {
|
||||
|
||||
export interface ProfileEditStore {
|
||||
edits(): AlignmentEdits;
|
||||
/** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */
|
||||
savedRadii(): Readonly<Record<string, number>>;
|
||||
replace(next: AlignmentEdits): void;
|
||||
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다.
|
||||
* 이때의 값이 [초기화]의 기준점이 된다. */
|
||||
@@ -209,6 +211,7 @@ function handleOf(
|
||||
|
||||
return {
|
||||
edits: () => state.current,
|
||||
savedRadii: () => state.saved.curve_radii ?? {},
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
state.unsaved = false;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface PanelToolsContext {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */
|
||||
savedRadii: () => Readonly<Record<string, number>>;
|
||||
/** false = 막힘(기울기 · 흙덮기). */
|
||||
applyEdits: (next: AlignmentEdits) => boolean;
|
||||
/** 고른 측점 id(없으면 null). */
|
||||
@@ -219,7 +221,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
if (tools.mode() === "shift") return tools.nudge(delta);
|
||||
const chainage = selectedChainage();
|
||||
if (chainage === null) return false;
|
||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()));
|
||||
return true;
|
||||
}
|
||||
// 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1)
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ProfileRenderContext {
|
||||
};
|
||||
store: {
|
||||
edits: () => AlignmentEdits;
|
||||
savedRadii: () => Readonly<Record<string, number>>;
|
||||
resetStation: (chainageM: number) => void;
|
||||
};
|
||||
detail: () => SectionDetailResponse | null;
|
||||
@@ -203,7 +204,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
||||
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
|
||||
onAdjustStation: (chainage, delta) =>
|
||||
base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
||||
base &&
|
||||
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -411,7 +413,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02).
|
||||
onStation: (chainage, delta) => {
|
||||
if (!base) return;
|
||||
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
|
||||
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii()));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Seat.ts
|
||||
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로.
|
||||
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 이웃 꼭짓점은 곡선 모양이
|
||||
* 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로.
|
||||
* 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438).
|
||||
*
|
||||
* 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes`
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
|
||||
/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */
|
||||
const SEAT_GROUND_M = 0.01;
|
||||
/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */
|
||||
const SEAT_KEEP_M = 0.001;
|
||||
|
||||
/** `np.median` 짝. */
|
||||
function median(values: number[]): number {
|
||||
@@ -24,11 +27,13 @@ function median(values: number[]): number {
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */
|
||||
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀 ·
|
||||
* `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(`followMovedNodes` 의 `held`). */
|
||||
export function seatBornNodes(
|
||||
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
|
||||
nodes: AlignmentNode[],
|
||||
born: ReadonlyArray<number>,
|
||||
held: ReadonlyArray<number> = [],
|
||||
): AlignmentNode[] {
|
||||
if (!nodes.length || base.chainage.length < 2) return nodes;
|
||||
const first = nodes[0].chainage_m;
|
||||
@@ -59,22 +64,40 @@ export function seatBornNodes(
|
||||
...fresh.map((s) => ({ s, z: seatZ(s), node: null })),
|
||||
].sort((a, b) => a.s - b.s);
|
||||
const baseS = rows.map((row) => row.s);
|
||||
const target = rows.map((row) => row.z);
|
||||
let baseZ = [...target];
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
// 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것
|
||||
const target = rows.map((row) => (row.node ? evaluateAt(preS, preZ, preCurves, row.s) : row.z));
|
||||
const can = rows.map(
|
||||
(row) => row.node !== null && held.every((value) => Math.abs(row.s - value) >= 0.006),
|
||||
);
|
||||
const free = rows.map((row) => row.node === null);
|
||||
let baseZ = rows.map((row) => row.z);
|
||||
const errorOf = (): number[] => {
|
||||
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
const curves = buildCurves(pvi, base.policy, {}, []);
|
||||
const error = rows.map((row, index) =>
|
||||
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
|
||||
return baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s));
|
||||
};
|
||||
for (let grow = 0; grow < rows.length; grow += 1) {
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
const error = errorOf().map((value, index) => (free[index] ? value : 0));
|
||||
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
|
||||
baseZ = baseZ.map((z, index) => z + error[index]);
|
||||
}
|
||||
// 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼)
|
||||
const drift = errorOf().map(
|
||||
(value, index) => can[index] && !free[index] && Math.abs(value) > SEAT_KEEP_M,
|
||||
);
|
||||
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
|
||||
baseZ = baseZ.map((z, index) => z + error[index]);
|
||||
if (!drift.includes(true)) break;
|
||||
drift.forEach((hit, index) => {
|
||||
if (hit) free[index] = true;
|
||||
});
|
||||
}
|
||||
return rows.map(
|
||||
(row, index) =>
|
||||
row.node ?? {
|
||||
chainage_m: Number(row.s.toFixed(6)),
|
||||
elevation_m: Number(baseZ[index].toFixed(6)),
|
||||
},
|
||||
return rows.map((row, index) =>
|
||||
row.node && !free[index]
|
||||
? row.node
|
||||
: {
|
||||
...row.node,
|
||||
chainage_m: Number(row.s.toFixed(6)),
|
||||
elevation_m: Number(baseZ[index].toFixed(6)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -561,12 +561,14 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
* 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1).
|
||||
* 누른 점 R 이 자동 R(`autoRadii` = 저장분 · 관 정착 등 엔진이 역산한 값)과 같으면 지워 기본 L(433-13) · 사용자 R 은 둠.
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
chainageM: number,
|
||||
delta: number,
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(chainageM);
|
||||
const current = buildAlignment(base, edits);
|
||||
@@ -580,6 +582,7 @@ export function adjustStation(
|
||||
const withOffset = (value: number): AlignmentEdits => {
|
||||
const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) };
|
||||
const radii = { ...edits.curve_radii };
|
||||
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
|
||||
axes?.inner.forEach((chainage) => {
|
||||
const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ];
|
||||
const z =
|
||||
|
||||
@@ -17,6 +17,16 @@ export function readAlignment(data: LongitudinalSection): ProfileAlignment | nul
|
||||
station_offsets: candidate.edits?.station_offsets ?? {},
|
||||
curve_radii: candidate.edits?.curve_radii ?? {},
|
||||
};
|
||||
// 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 채움
|
||||
// (433-13 · 서버 `rebuild_alignment_profile` 짝)
|
||||
const criteria = data.design_profiles?.[0]?.criteria;
|
||||
const policy = candidate.policy;
|
||||
if (criteria && (!policy.min_vertical_radius_m || !policy.min_curve_length_m))
|
||||
candidate.policy = {
|
||||
...policy,
|
||||
min_vertical_radius_m: policy.min_vertical_radius_m || criteria.min_vertical_radius_m || 0,
|
||||
min_curve_length_m: policy.min_curve_length_m || criteria.min_curve_length_m || 0,
|
||||
};
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,8 @@ export interface DesignProfile {
|
||||
samples: DesignProfileSample[];
|
||||
balance_segments: DesignProfileSegment[];
|
||||
summary: DesignProfileSummary;
|
||||
/** 첫 계획선 설계 기준(서버 `GradeDesignOptions.as_dict`) — 옛 정책 빈 법정 최소 R · L 채움(433-13). */
|
||||
criteria?: { min_vertical_radius_m?: number | null; min_curve_length_m?: number | null };
|
||||
}
|
||||
|
||||
export interface LongitudinalSection {
|
||||
|
||||
@@ -344,14 +344,15 @@ export function oneStationList<S extends ListedStation, P extends { chainage_m:
|
||||
* 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과 새 측점이 같은
|
||||
* 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 · 나머지 새 측점은
|
||||
* `seat`(세움은 부르는 쪽이 `seatBornNodes` · B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 짝 없는 옛
|
||||
* 꼭짓점은 지움. 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
|
||||
* 꼭짓점은 지움 · `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(서버 옮긴 `station_offsets` 키 · 433-5).
|
||||
* 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
|
||||
*/
|
||||
export function followMovedNodes<N extends { chainage_m: number }>(
|
||||
nodes: ReadonlyArray<N>,
|
||||
gone: ReadonlyArray<number>,
|
||||
born: ReadonlyArray<number>,
|
||||
edited: ReadonlyArray<string> = [],
|
||||
): { nodes: N[]; seat: number[] } {
|
||||
): { nodes: N[]; seat: number[]; held: number[] } {
|
||||
const target = (old: number): number | null => {
|
||||
const near = born.find((value) => Math.abs(value - old) < 0.006);
|
||||
if (near !== undefined) return near;
|
||||
@@ -379,5 +380,10 @@ export function followMovedNodes<N extends { chainage_m: number }>(
|
||||
const to = moves.get(old) ?? null;
|
||||
return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }];
|
||||
});
|
||||
return { nodes: kept, seat };
|
||||
const held = edited.flatMap((key) => {
|
||||
const old = gone.find((value) => Math.abs(value - Number(key)) < 0.006);
|
||||
const to = old === undefined ? Number(key) : (moves.get(old) ?? null);
|
||||
return to === null ? [] : [to];
|
||||
});
|
||||
return { nodes: kept, seat, held };
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ tools.createPanelTools({
|
||||
base: () => ({}),
|
||||
alignment: () => null,
|
||||
edits: () => ({}),
|
||||
savedRadii: () => ({}),
|
||||
applyEdits: () => undefined,
|
||||
selectedStationId: () => "irregular:p1",
|
||||
irregularStations: () => [pipe],
|
||||
|
||||
@@ -28,6 +28,6 @@ const station = loadTs("common_util/common_util_structure_station.ts");
|
||||
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts");
|
||||
const out = cases.map((item) => {
|
||||
const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited);
|
||||
return seat.seatBornNodes(item.base, moved.nodes, moved.seat);
|
||||
return seat.seatBornNodes(item.base, moved.nodes, moved.seat, moved.held);
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림.
|
||||
* ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로
|
||||
* ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = 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);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const align = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
|
||||
);
|
||||
|
||||
// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %)
|
||||
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
|
||||
const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0));
|
||||
const base = {
|
||||
policy: {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 1,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: false,
|
||||
default_curve_length_m: 15,
|
||||
default_curve_radius_m: 300,
|
||||
balance_tolerance_percent: 10,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 0,
|
||||
paved: true,
|
||||
},
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
|
||||
chainage: stations,
|
||||
ground,
|
||||
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
const KEY = "100.000";
|
||||
const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A)
|
||||
|
||||
function press(radius, autoRadii) {
|
||||
const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } };
|
||||
const next =
|
||||
autoRadii === undefined
|
||||
? align.adjustStation(base, edits, 100, 0.1)
|
||||
: align.adjustStation(base, edits, 100, 0.1, autoRadii);
|
||||
const curve = align
|
||||
.buildAlignment(base, next)
|
||||
.curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6);
|
||||
return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null };
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
auto: press(AUTO, { [KEY]: AUTO }),
|
||||
user: press(777, { [KEY]: AUTO }),
|
||||
legacy: press(AUTO),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
/* 455 (433-13 ③) 옛 정책 경고 — 진짜 `B05_Profile_UI_Profile_Data.ts` readAlignment +
|
||||
* `B05_Profile_UI_Profile_Alignment.ts` buildAlignment 를 Node 로 돌림.
|
||||
* 입력 argv[2] = 종단 정본 JSON · 표준출력 = { warnings: [법정 미달 글], policy }.
|
||||
*/
|
||||
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 data = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Data.ts"));
|
||||
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const longitudinal = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const stored = data.readAlignment(longitudinal);
|
||||
const built = align.buildAlignment(align.toAlignmentBase(stored), align.emptyEdits());
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
warnings: built.warnings.filter((text) => text.includes("법정 최소")),
|
||||
policy: stored.policy,
|
||||
}),
|
||||
);
|
||||
@@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None:
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "if (event.defaultPrevented) return;" in src
|
||||
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src
|
||||
assert (
|
||||
"ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src
|
||||
)
|
||||
|
||||
|
||||
def _ground(level: float) -> list[dict]:
|
||||
|
||||
@@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case):
|
||||
|
||||
|
||||
def test_부르는_곳_셋이_같은_함수():
|
||||
assert "adjustStation(base, store.edits(), chainage, delta)" in _read(
|
||||
# 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김
|
||||
assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Profile_Render.ts"
|
||||
)
|
||||
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read(
|
||||
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts"
|
||||
)
|
||||
assert "pivotAxes(current, chainageM)" in _read(
|
||||
|
||||
@@ -195,3 +195,63 @@ def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None:
|
||||
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
|
||||
for mine, theirs in zip(nodes, server["base_pvi"]):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
|
||||
|
||||
def _balanced() -> dict:
|
||||
"""지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴)."""
|
||||
data = _longitudinal()
|
||||
for node in data["profile_alignment"]["base_pvi"]:
|
||||
node["elevation_m"] -= 1.0
|
||||
alignment, profile = rebuild_alignment_profile(data, {})
|
||||
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make", [_longitudinal, _balanced])
|
||||
def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None:
|
||||
"""관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고."""
|
||||
first = make()
|
||||
before = _rows(first, 0, 400)
|
||||
_write(tmp_path, first)
|
||||
after = _rows(_move(tmp_path, 356.0), 0, 400)
|
||||
for at, value in before.items():
|
||||
if at != PIPE:
|
||||
assert abs(after[at] - value) < 0.01, at
|
||||
if make is _longitudinal:
|
||||
assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4)
|
||||
# 화면 짝 같은 꼭짓점
|
||||
stored = first["profile_alignment"]
|
||||
base = {
|
||||
"basePvi": stored["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in stored["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
|
||||
"policy": stored["policy"],
|
||||
}
|
||||
server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"]
|
||||
[nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": []}])
|
||||
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
|
||||
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
|
||||
|
||||
def test_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None:
|
||||
"""340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝."""
|
||||
first = _longitudinal({"340.000": 0.3})
|
||||
stored = copy.deepcopy(first["profile_alignment"])
|
||||
_write(tmp_path, first)
|
||||
server = _move(tmp_path, 356.0)["profile_alignment"]
|
||||
old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]}
|
||||
new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]}
|
||||
assert new[340.0] == old[340.0]
|
||||
assert new[360.0] != old[360.0]
|
||||
base = {
|
||||
"basePvi": stored["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in stored["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
|
||||
"policy": stored["policy"],
|
||||
}
|
||||
[nodes] = _ts(
|
||||
tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}]
|
||||
)
|
||||
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L.
|
||||
|
||||
까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로
|
||||
짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠.
|
||||
계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_455_arrow_auto_radius.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_자동_R_은_지워_기본_L(out) -> None:
|
||||
assert out["auto"]["radius"] is None
|
||||
assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6)
|
||||
# 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거
|
||||
assert out["legacy"]["radius"] == pytest.approx(3000.0)
|
||||
assert out["legacy"]["length"] < 14.0
|
||||
|
||||
|
||||
def test_사용자_R_은_그대로(out) -> None:
|
||||
assert out["user"]["radius"] == 777
|
||||
|
||||
|
||||
def test_부르는_곳_셋이_저장_R_을_넘김() -> None:
|
||||
def read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
|
||||
assert render.count("chainage, delta, store.savedRadii())") == 2
|
||||
tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools
|
||||
assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""455 (433-13 ②) 폴백 첫 종단 직선화 — 관 정착 · 직선 분할도 R > 500 m 꼭짓점 없음.
|
||||
|
||||
까닭: 초기값 직선화(438)가 1순위 지반 추종 선형에만 걸려, 1순위가 실패해 2 · 3순위(관 정착 ·
|
||||
직선 분할)로 내려가면 R > 500 m 꼭짓점이 남음. 사용자 확정(269-2) — 직선화 높이 한도 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_curves,
|
||||
is_straight_bend,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_alignment_profile,
|
||||
design_pipe_anchored_profile,
|
||||
)
|
||||
|
||||
PIPES = [60.0, 125.0, 190.0, 260.0, 330.0]
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.06 * s + 2.5 * math.sin(s / 45.0) + 0.05 * math.sin(s / 6.0)
|
||||
|
||||
|
||||
def _longitudinal() -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
return {"samples": samples, "stations": stations}
|
||||
|
||||
|
||||
OPTIONS = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=100.0,
|
||||
min_curve_length_m=20.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=20,
|
||||
)
|
||||
|
||||
|
||||
def _flat(alignment: dict) -> tuple[list[float], int]:
|
||||
"""(R > 500 m 꺾임 측점 · 직선 위로 옮긴 꼭짓점 수) — 438 시험과 같은 판정."""
|
||||
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(alignment["policy"])
|
||||
grades = np.diff(z) / np.diff(s)
|
||||
straight = np.zeros(len(s), dtype=bool)
|
||||
for i in range(1, len(s) - 1):
|
||||
straight[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i])
|
||||
flat = flat_bends(s, z, policy.default_curve_length_m) & ~straight
|
||||
assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"])
|
||||
return s[flat].tolist(), int(straight.sum())
|
||||
|
||||
|
||||
def test_관_정착_선형_R500_넘는_꼭짓점_없음() -> None:
|
||||
alignment, _profile = design_pipe_anchored_profile(_longitudinal(), OPTIONS, PIPES)
|
||||
flat, straight = _flat(alignment)
|
||||
assert flat == [] and 0 < straight < len(PIPES)
|
||||
# 직선 위로 옮긴 관 꼭짓점은 자동 R 을 안 남김 · 남은 꺾임은 곡선이 관 자리를 지남
|
||||
radii = alignment["edits"]["curve_radii"]
|
||||
kinks = {n["chainage_m"] for n in alignment["base_pvi"][1:-1]}
|
||||
assert set(map(float, radii)) <= kinks
|
||||
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(alignment["policy"]), radii)
|
||||
assert all(float(c["chainage_m"]) in kinks for c in curves)
|
||||
|
||||
|
||||
def test_직선_분할_선형_R500_넘는_꼭짓점_없음(monkeypatch) -> None:
|
||||
alignment, _profile = design_alignment_profile(_longitudinal(), OPTIONS)
|
||||
assert _flat(alignment)[0] == []
|
||||
# 풀이가 R > 500 m 꺾임(100 m · 대수차 0.5 %)을 낸 꼴 — 직선 위로 · 200 m 꺾임은 그대로
|
||||
import B05_Profile.B05_Profile_Engine_Grade_Profile as engine
|
||||
|
||||
s = np.array([0.0, 100.0, 200.0, 400.0])
|
||||
z = np.array([100.0, 103.0, 106.5, 124.5])
|
||||
monkeypatch.setattr(engine, "station_breakpoints", lambda *_a, **_k: s)
|
||||
monkeypatch.setattr(engine, "solve_alignment_elevations", lambda *_a, **_k: (z.copy(), True))
|
||||
alignment, _profile = engine.design_alignment_profile(_longitudinal(), OPTIONS)
|
||||
flat, straight = _flat(alignment)
|
||||
assert flat == [] and straight == 1
|
||||
nodes = {n["chainage_m"]: n["elevation_m"] for n in alignment["base_pvi"]}
|
||||
assert nodes[100.0] == pytest.approx(103.25)
|
||||
assert nodes[200.0] == pytest.approx(106.5)
|
||||
|
||||
|
||||
def test_부르는_곳() -> None:
|
||||
src = (ROOT / "B05_Profile" / "B05_Profile_Engine_Grade_Profile.py").read_text(encoding="utf-8")
|
||||
assert src.count("flat_bends(base_s, ") >= 3 # 지반 추종(straight_pass_through 안) 밖 둘
|
||||
|
||||
|
||||
def test_여유로_들린_관_꼭짓점은_그대로() -> None:
|
||||
"""관 여유 강제(+1.5 m)로 들린 관 꼭짓점 = 직선화 경계 · 여유 없는 관 꼭짓점만 직선 위."""
|
||||
clearances = {125.0: 1.5}
|
||||
alignment, _profile = design_pipe_anchored_profile(
|
||||
_longitudinal(), OPTIONS, PIPES, pipe_clearances=clearances
|
||||
)
|
||||
flat, straight = _flat(alignment)
|
||||
assert straight > 0
|
||||
assert flat in ([], [125.0]) # 들린 꼭짓점만 R > 500 이 남을 수 있음
|
||||
rows = {r["chainage_m"]: r["plan_elevation_m"] for r in alignment["stations"]}
|
||||
plan = np.interp(125.0, list(rows), list(rows.values()))
|
||||
assert plan > _ground(125.0) + 1.0
|
||||
@@ -0,0 +1,105 @@
|
||||
"""455 (433-13 ③) 옛 정책 종단 — 법정 최소 R · L 미달 경고가 초안 · 저장 뒤 같음.
|
||||
|
||||
까닭: 옛 저장 정책엔 `min_vertical_radius_m` · `min_curve_length_m` 가 없어 화면 초안(저장
|
||||
정책 그대로) 경고 0 · 노선 [저장](새 정책)에서야 뜸. 고침: 빈 값은 첫 계획선 설계 기준(criteria =
|
||||
설계속도 법정값)
|
||||
— TS `readAlignment` ↔ 서버 `rebuild_alignment_profile` 같은 규칙(`helper_455_old_policy.cjs`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
rebuild_alignment_profile,
|
||||
)
|
||||
|
||||
# 설계속도 40 — 법정 최소 R 450 · L 40 > 기본 L 20 → 곡선마다 미달
|
||||
OPTIONS = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=450.0,
|
||||
min_curve_length_m=40.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=40,
|
||||
)
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 30.0)
|
||||
|
||||
|
||||
def _fresh() -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
data = {"length_m": 400.0, "samples": samples, "stations": stations}
|
||||
alignment, profile = design_ground_following_profile(data, OPTIONS, station_interval_m=20.0)
|
||||
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
|
||||
return data
|
||||
|
||||
|
||||
def _old() -> dict:
|
||||
data = _fresh()
|
||||
for key in ("min_vertical_radius_m", "min_curve_length_m"):
|
||||
data["profile_alignment"]["policy"].pop(key)
|
||||
return data
|
||||
|
||||
|
||||
def _law(warnings: list[str]) -> list[str]:
|
||||
return [text for text in warnings if "법정 최소" in text]
|
||||
|
||||
|
||||
def _ts(tmp_path: Path, data: dict) -> dict:
|
||||
source = tmp_path / "in455.json"
|
||||
source.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_455_old_policy.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_새_정책_경고_기준(tmp_path: Path) -> None:
|
||||
fresh = _fresh()
|
||||
law = _law(fresh["profile_alignment"]["warnings"])
|
||||
assert law
|
||||
assert _ts(tmp_path, fresh)["warnings"] == law
|
||||
|
||||
|
||||
def test_옛_정책_초안_경고_저장_뒤와_같음(tmp_path: Path) -> None:
|
||||
expected = _law(_fresh()["profile_alignment"]["warnings"])
|
||||
old = _old()
|
||||
ts = _ts(tmp_path, copy.deepcopy(old))
|
||||
assert ts["policy"]["min_vertical_radius_m"] == 450.0
|
||||
assert ts["policy"]["min_curve_length_m"] == 40.0
|
||||
assert ts["warnings"] == expected
|
||||
alignment, _profile = rebuild_alignment_profile(old, {})
|
||||
assert _law(alignment["warnings"]) == expected
|
||||
assert alignment["policy"]["min_curve_length_m"] == 40.0
|
||||
|
||||
|
||||
def test_criteria_없는_판은_그대로(tmp_path: Path) -> None:
|
||||
old = _old()
|
||||
old["design_profiles"][0].pop("criteria")
|
||||
assert _ts(tmp_path, copy.deepcopy(old))["warnings"] == []
|
||||
Reference in New Issue
Block a user