fix(455 433-5 관 옮김 이웃): 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞춤 — 바로 앞뒤부터 1mm 넘는 만큼 · 높이 고친 꼭짓점 그대로 · TS ↔ 파이썬 짝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) ·
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user