fix(455 433-5 관 옮김 이웃): 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞춤 — 바로 앞뒤부터 1mm 넘는 만큼 · 높이 고친 꼭짓점 그대로 · TS ↔ 파이썬 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 21:28:08 +09:00
co-authored by Claude Opus 5.5
parent d0779af302
commit 19bca9287b
6 changed files with 151 additions and 33 deletions
+1 -1
View File
@@ -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,
+42 -13
View File
@@ -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) ·
+38 -15
View File
@@ -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)),
},
);
}
+9 -3
View File
@@ -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 };
}
@@ -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));
@@ -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)