diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts index e7410f0c6..ddad9ab42 100644 --- a/A00_Common/design/design_profile_base.ts +++ b/A00_Common/design/design_profile_base.ts @@ -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, diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index f03524cf5..d9a1c43cc 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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) · diff --git a/B05_Profile/logic/B05_Profile_Grade_Seat.ts b/B05_Profile/logic/B05_Profile_Grade_Seat.ts index 7d42b50c2..d9fcd7330 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Seat.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Seat.ts @@ -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, nodes: AlignmentNode[], born: ReadonlyArray, + held: ReadonlyArray = [], ): 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)), + }, ); } diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index b6eda5730..09b644cbc 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -344,14 +344,15 @@ export function oneStationList( nodes: ReadonlyArray, gone: ReadonlyArray, born: ReadonlyArray, edited: ReadonlyArray = [], -): { 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( 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 }; } diff --git a/resources/tester/helper_427_1_follow_seat.cjs b/resources/tester/helper_427_1_follow_seat.cjs index 0d915ff69..9cedb64f9 100644 --- a/resources/tester/helper_427_1_follow_seat.cjs +++ b/resources/tester/helper_427_1_follow_seat.cjs @@ -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)); diff --git a/resources/tester/test_427_1_follow_seat.py b/resources/tester/test_427_1_follow_seat.py index 6b5408ca1..23a316edd 100644 --- a/resources/tester/test_427_1_follow_seat.py +++ b/resources/tester/test_427_1_follow_seat.py @@ -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)