Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-10 15:25:41 +09:00
4 changed files with 373 additions and 12 deletions
@@ -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
+24 -10
View File
@@ -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
@@ -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<string, number>, stationS: number[]): Record<string, number> {
const best = new Map<string, { gap: number; value: number }>();
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<number, { gap: number; node: AlignmentNode }>();
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);
@@ -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) == []