From 002165f040d2d3c8cbfcf2b8c0b1e9e46210ee2c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:22:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05=C2=B7B06):=20=ED=95=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=B8=A1=EC=A0=90=20=EB=91=98=20=EC=88=A8=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20=EC=B8=A1=EC=A0=90=200.006=20m=20=EC=95=88=20?= =?UTF-8?q?=EA=BC=AD=EC=A7=93=EC=A0=90=20=C2=B7=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EA=B7=B8=20=EC=B8=A1=EC=A0=90=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9D=8C(213-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 자동설계 관 측점 263.206 → 화면 저장 0.01 m 반올림 263.21 로 측점이 다시 섬 · 계획선 꼭짓점 · 편집 키는 옛 글자 그대로 → 새 측점 편집과 0.004 m 사이 두 꼭짓점(−15003% 계단 · 엄상준_검증 No.13+3) - 계획선 공용 함수 짝(파이썬 snap_to_stations ↔ TS snapToStations) — 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이(측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로 · 읽을 때 · [저장] 때 모두 정리 - 192 병합(_follow_moved_stations)도 옛 측점 0.006 m 안 글자를 같은 측점으로 봄 - 시험 test_213_1(합성 · 거울 · 엄상준 사본 재현) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B05_Profile_Engine_Grade_Alignment.py | 72 ++++++ B05_Profile/B05_Profile_Router_Confirm.py | 34 ++- .../B05_Profile_UI_Profile_Alignment.ts | 65 +++++- .../tester/test_213_1_hidden_station_pvi.py | 214 ++++++++++++++++++ 4 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_213_1_hidden_station_pvi.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 254b252d8..82e8e274f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -158,6 +158,62 @@ def tangent_elevation(pvi_s: np.ndarray, pvi_z: np.ndarray, targets: np.ndarray) return np.interp(targets, pvi_s, pvi_z) +#: 반올림만 다른 같은 측점(m) — 관 측점 0.001m(자동설계) ↔ 0.01m(화면 저장) 차이. +#: 화면 `SAME_STATION_SNAP_M`(B05_Profile_UI_Profile_Alignment.ts) 와 짝. +SAME_STATION_SNAP_M = 0.006 + + +def _nearest_station(value: float, station_s: np.ndarray) -> float | None: + if not station_s.size: + return None + near = float(station_s[int(np.argmin(np.abs(station_s - value)))]) + return near if abs(near - value) < SAME_STATION_SNAP_M else None + + +def snap_to_stations( + base_s: np.ndarray, + base_z: np.ndarray, + edits: dict[str, dict[str, float]], + station_s: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, dict[str, dict[str, float]]]: + """측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1). + + 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 꼭짓점 · 편집 키는 옛 글자로 남아, 새 측점 + 편집과 0.004m 사이 두 꼭짓점이 됨(−15000% 계단 · 숨은 측점). 겹치면 측점 위 것이 이김. + 옮긴 꼭짓점은 옛 선의 측점 높이를 받아 측점 계획고가 안 바뀜. 양 끝 · 측점에서 먼 키는 그대로. + """ + last = len(base_s) - 1 + nodes: dict[float, tuple[float, float, float]] = {} + for index, (s, z) in enumerate(zip(base_s.tolist(), base_z.tolist())): + near = None if index in (0, last) else _nearest_station(s, station_s) + at = s if near is None else near + gap = 0.0 if near is None else abs(near - s) + slot = round(at, _KEY_DECIMALS) + if slot not in nodes or gap < nodes[slot][0]: + # 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로 + nodes[slot] = (gap, at, z if near is None else float(np.interp(at, base_s, base_z))) + ordered = [nodes[slot] for slot in sorted(nodes)] + snapped: dict[str, dict[str, float]] = {} + for name, values in edits.items(): + best: dict[str, tuple[float, float]] = {} + for key, value in values.items(): + try: + chainage = float(key) + except (TypeError, ValueError): + chainage = float("nan") + near = _nearest_station(chainage, station_s) if np.isfinite(chainage) else None + target = key if near is None else chainage_key(near) + gap = 0.0 if near is None else abs(near - chainage) + if target not in best or gap < best[target][0]: + best[target] = (gap, value) + snapped[name] = {key: value for key, (_, value) in best.items()} + return ( + np.array([item[1] for item in ordered], dtype=np.float64), + np.array([item[2] for item in ordered], dtype=np.float64), + snapped, + ) + + def resolve_pvi( base_s: np.ndarray, base_z: np.ndarray, @@ -407,6 +463,22 @@ def build_alignment( if value is not None } + station_s = np.array( + sorted( + float(station["chainage_m"]) + for station in stations + if isinstance(station.get("chainage_m"), (int, float)) + ), + dtype=np.float64, + ) + base_s, base_z, snapped = snap_to_stations( + base_s, + base_z, + {"station_offsets": station_offsets, "curve_radii": curve_radii}, + station_s, + ) + station_offsets, curve_radii = snapped["station_offsets"], snapped["curve_radii"] + pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) curves, warnings = build_curves( pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index c21c05004..8e5117630 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -214,27 +214,41 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st None, ) moves[chainage_key(old)] = None if near is None else chainage_key(near) + + def moved(value: float) -> tuple[bool, str | None]: + """옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1). + 남은 측점 위 값은 그대로.""" + if chainage_key(value) in after_keys: + return False, None + for old_key, new_key in moves.items(): + if abs(float(old_key) - value) < _SAME_STATION_M: + return True, new_key + return False, None + edits = stored.get("edits") or {} next_edits: dict[str, dict[str, Any]] = {} changed = False for name in ("station_offsets", "curve_radii"): values = dict(edits.get(name) or {}) - for old_key, new_key in moves.items(): - if old_key in values: - value = values.pop(old_key) - if new_key is not None: - values.setdefault(new_key, value) - changed = True + # 옛 측점 글자 그대로인 키가 먼저 — 반올림 글자보다 이김 + for key in sorted(values, key=lambda item: item not in moves): + hit, new_key = moved(float(key)) + if not hit: + continue + value = values.pop(key) + if new_key is not None: + values.setdefault(new_key, value) + changed = True next_edits[name] = values base: list[dict[str, Any]] = [] for node in stored["base_pvi"]: - key = chainage_key(node["chainage_m"]) - if key not in moves: + hit, new_key = moved(float(node["chainage_m"])) + if not hit: base.append(node) continue changed = True - if moves[key] is not None: - base.append({**node, "chainage_m": float(moves[key])}) + if new_key is not None: + base.append({**node, "chainage_m": float(new_key)}) if not changed: return None stored["base_pvi"] = base diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 1a6c62b19..c30957a82 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -182,6 +182,65 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { }; } +/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */ +const SAME_STATION_SNAP_M = 0.006; + +function nearestStation(value: number, stationS: number[]): number | null { + let near: number | null = null; + stationS.forEach((station) => { + if (near === null || Math.abs(station - value) < Math.abs(near - value)) near = station; + }); + return near !== null && Math.abs(near - value) < SAME_STATION_SNAP_M ? near : null; +} + +function snapKeys(values: Record, stationS: number[]): Record { + const best = new Map(); + Object.entries(values).forEach(([key, value]) => { + const chainage = Number(key); + const near = Number.isFinite(chainage) ? nearestStation(chainage, stationS) : null; + const target = near === null ? key : chainageKey(near); + const gap = near === null ? 0 : Math.abs(near - chainage); + const held = best.get(target); + if (!held || gap < held.gap) best.set(target, { gap, value }); + }); + return Object.fromEntries([...best].map(([key, entry]) => [key, entry.value])); +} + +/** + * 측점 0.006m 안의 꼭짓점(base_pvi) · 편집 키를 그 측점으로 모은다(213-1) — 서버 + * `snap_to_stations` 와 같은 규칙. 관 측점이 반올림으로 옮겨도(263.206 → 263.21) 옛 글자로 남아 + * 0.004m 사이 두 꼭짓점이 되던 흠. 겹치면 측점 위 것이 이김 · 옮긴 꼭짓점은 옛 선의 측점 높이 + * (측점 계획고 그대로) · 양 끝 · 측점에서 먼 키는 그대로. + */ +function snapToStations( + base: AlignmentBase, + edits: AlignmentEdits, +): { basePvi: AlignmentNode[]; edits: AlignmentEdits } { + const stationS = base.stations.map((station) => station.chainage_m); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const last = base.basePvi.length - 1; + const nodes = new Map(); + base.basePvi.forEach((node, index) => { + const near = index === 0 || index === last ? null : nearestStation(node.chainage_m, stationS); + const at = near ?? node.chainage_m; + const gap = near === null ? 0 : Math.abs(near - node.chainage_m); + const slot = Number(at.toFixed(3)); + const held = nodes.get(slot); + // 옮긴 꼭짓점 높이 = 옛 선의 그 측점 높이 — 측점 계획고가 그대로 + const elevation = near === null ? node.elevation_m : interpolate(baseS, baseZ, at); + if (!held || gap < held.gap) + nodes.set(slot, { gap, node: { chainage_m: at, elevation_m: elevation } }); + }); + return { + basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!.node), + edits: { + station_offsets: snapKeys(edits.station_offsets, stationS), + curve_radii: snapKeys(edits.curve_radii, stationS), + }, + }; +} + /** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */ function resolvePvi( base: AlignmentBase, @@ -316,8 +375,10 @@ function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits { }; } -export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment { - const edits = normalizeEdits(input); +export function buildAlignment(raw: AlignmentBase, input: AlignmentEdits): ProfileAlignment { + const snapped = snapToStations(raw, normalizeEdits(input)); + const base: AlignmentBase = { ...raw, basePvi: snapped.basePvi }; + const edits = snapped.edits; const warnings: string[] = []; const nodes = resolvePvi(base, edits.station_offsets); const pviS = nodes.map((node) => node.chainage_m); diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py new file mode 100644 index 000000000..4e5480c8c --- /dev/null +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -0,0 +1,214 @@ +"""213-1 — 한 자리에 측점 둘이 숨는 흠(엄상준_검증 d2245e3b · No.13+3). + +까닭: 자동설계 관 측점 263.206(0.001m) → 화면 저장이 관을 0.01m 로 반올림(263.21) → 측점은 +263.21 로 다시 섰는데 계획선 꼭짓점(base_pvi) · 편집 키는 옛 글자 「263.206」 그대로 → 새 측점 +편집 「263.210」과 0.004m 사이 두 꼭짓점(−15003% 계단). +고침: 계획선 공용 함수(파이썬 `snap_to_stations` ↔ TS `snapToStations`)가 측점 0.006m 안의 +꼭짓점 · 편집 키를 그 측점으로 모음(측점 위 것이 이김 · 측점 계획고 그대로) · 192 병합도 +옛 글자를 같은 측점으로 봄. 사용자 storage 는 읽기만 — 사본(tmp_path). +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_alignment, +) +from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402 +from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402 + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SOURCE = ( + PROJECT_ROOT + / "storage/1/3/d2245e3b-0468-4a90-ac5c-50b2adcb56e7/B06_Section/longitudinal/longitudinal.json" +) +REL = "B06_Section/longitudinal/longitudinal.json" +needs_copy = pytest.mark.skipif(not SOURCE.is_file(), reason="엄상준_검증 storage 없음") + +# 합성 — 격자 20m · 관 측점 263.206 → 263.21 반올림 · 끝 꼭짓점 1000.0(끝 측점 999.998) +STATIONS = [0.0, 20.0, 40.0, 263.21, 280.0, 300.0, 999.998] +BASE = [ + (0.0, 100.0), + (20.0, 99.0), + (40.0, 98.5), + (263.206, 90.0), + (280.0, 89.0), + (300.0, 88.0), + (1000.0, 60.0), +] +EDITS = { + "station_offsets": {"263.206": 1.5, "263.210": 0.9, "40.000": 0.2, "500.000": 0.3}, + "curve_radii": {"263.206": 300.0}, +} + + +def _synthetic() -> dict: + chainage = np.arange(0.0, 1000.0, 1.0) + return { + "base_s": np.array([s for s, _ in BASE]), + "base_z": np.array([z for _, z in BASE]), + "chainage": np.r_[chainage, 1000.0], + "ground": np.r_[100.0 - chainage * 0.04, 60.0], + "stations": [ + {"station_id": f"st{index}", "chainage_m": value} + for index, value in enumerate(STATIONS) + ], + } + + +def _python(edits: dict) -> dict: + data = _synthetic() + return build_alignment(**data, policy=AlignmentPolicy.from_dict({}), edits=copy.deepcopy(edits)) + + +def _close_pairs(alignment: dict, limit: float = 0.006) -> list[tuple[float, float]]: + pvi = [node["chainage_m"] for node in alignment["pvi"]] + return [(a, b) for a, b in zip(pvi, pvi[1:], strict=False) if b - a < limit] + + +def test_rounded_station_keys_collapse_onto_station(): + alignment = _python(EDITS) + pvi = {round(node["chainage_m"], 3): node for node in alignment["pvi"]} + assert _close_pairs(alignment) == [] + assert 263.206 not in pvi and 263.21 in pvi + # 측점 위 편집(263.210 = 0.9)이 이김 · 반경도 측점으로 옮김 · 측점에서 먼 키(500)는 그대로 + assert alignment["edits"]["station_offsets"] == {"263.210": 0.9, "40.000": 0.2, "500.000": 0.3} + assert alignment["edits"]["curve_radii"] == {"263.210": 300.0} + # 양 끝 꼭짓점은 그대로(끝 1000.0 을 측점 999.998 로 당기지 않음) + assert [node["chainage_m"] for node in alignment["base_pvi"]][-1] == 1000.0 + assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100 + + +def test_snapped_base_keeps_station_elevation(): + """편집 없는 측점 — 꼭짓점을 측점으로 옮겨도 그 측점 계획고(탄젠트)는 그대로.""" + alignment = _python({"station_offsets": {}, "curve_radii": {}}) + node = next(n for n in alignment["base_pvi"] if abs(n["chainage_m"] - 263.21) < 1e-9) + expected = float(np.interp(263.21, [s for s, _ in BASE], [z for _, z in BASE])) + assert node["elevation_m"] == pytest.approx(expected, abs=1e-6) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { buildAlignment } from "./B05_Profile_UI_Profile_Alignment.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const result = buildAlignment(input.base, input.edits); +writeFileSync(process.argv[3], JSON.stringify(result)); +""" + + +@pytest.fixture(scope="module") +def ts_alignment(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("align213") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Alignment.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + data = _synthetic() + base = { + "policy": AlignmentPolicy.from_dict({}).as_dict(), + "onlyExplicitCurves": False, + "basePvi": [{"chainage_m": s, "elevation_m": z} for s, z in BASE], + "chainage": data["chainage"].tolist(), + "ground": data["ground"].tolist(), + "stations": data["stations"], + } + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text(json.dumps({"base": base, "edits": EDITS}), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def test_mirror_python_ts(ts_alignment): + py = _python(EDITS) + ts = ts_alignment + assert ts["edits"] == py["edits"] + assert len(ts["base_pvi"]) == len(py["base_pvi"]) + for a, b in zip(ts["base_pvi"], py["base_pvi"], strict=True): + assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-6) + assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6) + assert [n["chainage_m"] for n in ts["pvi"]] == pytest.approx( + [n["chainage_m"] for n in py["pvi"]], abs=1e-6 + ) + assert [n["elevation_m"] for n in ts["pvi"]] == pytest.approx( + [n["elevation_m"] for n in py["pvi"]], abs=1e-6 + ) + for a, b in zip(ts["stations"], py["stations"], strict=True): + assert a["plan_elevation_m"] == pytest.approx(b["plan_elevation_m"], abs=1e-6) + + +def _copy(tmp_path: Path) -> Path: + target = tmp_path / REL + target.parent.mkdir(parents=True) + shutil.copyfile(SOURCE, target) + return target + + +@needs_copy +def test_d2245e3b_no13_3_spike_gone(tmp_path): + data = json.loads(_copy(tmp_path).read_text(encoding="utf-8")) + edits = data["profile_alignment"]["edits"] + assert {"263.206", "263.210"} <= set(edits["station_offsets"]) # 재현 조건 + alignment, _ = rebuild_alignment_profile(data, edits) + assert _close_pairs(alignment) == [] + assert "263.206" not in alignment["edits"]["station_offsets"] + assert max(abs(segment["grade_percent"]) for segment in alignment["segments"]) < 100 + + +@needs_copy +def test_d2245e3b_station_regen_drops_rounded_key(tmp_path): + """[측점 만들기] 로 관 측점 263.21 → 263.31 — 옛 글자 263.206 꼭짓점 · 편집도 함께 지워짐.""" + path = _copy(tmp_path) + data = json.loads(path.read_text(encoding="utf-8")) + irregular = [] + for station in data["stations"]: + if station.get("kind") != "irregular": + continue + item = copy.deepcopy(station) + if abs(item["chainage_m"] - 263.21) < 1e-6: + item["chainage_m"] = 263.31 + irregular.append(item) + _merge_irregular_into_longitudinal(tmp_path, REL, irregular) + saved = json.loads(path.read_text(encoding="utf-8"))["profile_alignment"] + leftovers = [ + value + for value in [n["chainage_m"] for n in saved["base_pvi"]] + + [float(key) for key in saved["edits"]["station_offsets"]] + if 263.2 < value < 263.22 + ] + assert leftovers == [] + assert _close_pairs(saved, 0.05) == []