Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1
This commit is contained in:
@@ -46,7 +46,17 @@ const out = {
|
||||
return {
|
||||
span,
|
||||
result: span
|
||||
? carry.carryProfileEdits(item.base_pvi, item.edits, span, item.ground_pvi)
|
||||
? {
|
||||
basePvi: carry.carryBase(item.old_xy, item.new_xy, item.base_pvi, item.ground_pvi),
|
||||
edits: carry.carryEdits(
|
||||
item.old_xy,
|
||||
item.new_xy,
|
||||
item.base_pvi,
|
||||
item.edits,
|
||||
item.stations,
|
||||
10,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 349-0v 바뀐 구간 거울 — 화면 planline 단계 차례 그대로: 새 노선 = `buildEditedPolyline`(노드 초안) ·
|
||||
* 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryProfileEdits`. 입력 argv[2] JSON · 출력 JSON.
|
||||
* 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryBase` · `carryEdits`(400). 입력 argv[2] JSON · 출력 JSON.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -37,13 +37,18 @@ const built = curve.buildEditedPolyline(
|
||||
input.curveRadius,
|
||||
input.minRadiusM,
|
||||
);
|
||||
const span = carry.changedSpan(xy(input.old_line), xy(built.vertices));
|
||||
const old = xy(input.old_line);
|
||||
const now = xy(built.vertices);
|
||||
const span = carry.changedSpan(old, now);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
new_line: built.vertices,
|
||||
span,
|
||||
result: span
|
||||
? carry.carryProfileEdits(input.base_pvi, input.edits, span, input.ground_pvi)
|
||||
? {
|
||||
basePvi: carry.carryBase(old, now, input.base_pvi, input.ground_pvi),
|
||||
edits: carry.carryEdits(old, now, input.base_pvi, input.edits, input.stations, 20),
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* 400 계획고 이월 거울 — 진짜 TS(`logic/B05_Profile_Grade_Carry` · `logic/B05_Profile_Pipeline_Grade` follow)를
|
||||
* Node 로 돌림. 입력 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function loadTs(file, stubs = {}) {
|
||||
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (stubs[request]) return stubs[request];
|
||||
throw new Error(`stub 없음: ${request} (${file})`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||
});
|
||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
|
||||
"../../common_util/common_util_route_follow": {},
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
"./B05_Profile_Grade_Carry": carry,
|
||||
"./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 },
|
||||
"./B05_Profile_Grade_Ground": ground,
|
||||
});
|
||||
|
||||
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
|
||||
const edits = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts", {
|
||||
"./B05_Profile_UI_RouteEdit_Curve": curve,
|
||||
});
|
||||
const frame = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts");
|
||||
const fit = loadTs("B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts", {
|
||||
"./B05_Profile_UI_RouteEdit_Curve": curve,
|
||||
"./B05_Profile_UI_RouteEdit_Edits": edits,
|
||||
"./B05_Profile_UI_RouteEdit_Frame": frame,
|
||||
});
|
||||
|
||||
/** 측점 스냅 켠 노선 — 저장 노선(맞춤) · 교점 하나 옮긴 뒤 맞춤(편집기와 같은 `fitCurveStations`). */
|
||||
function snapFit(item) {
|
||||
const n = item.nodes.length;
|
||||
const cols = (planned) => ({
|
||||
planned,
|
||||
curveOn: Array(n).fill(true),
|
||||
curveRadius: Array(n).fill(null),
|
||||
curveLock: Array(n).fill(null),
|
||||
curveArc: Array(n).fill(null),
|
||||
apexLock: Array(n).fill(false),
|
||||
});
|
||||
const line = (planned) =>
|
||||
curve.buildEditedPolyline(
|
||||
planned,
|
||||
Array(n).fill(true),
|
||||
Array(n).fill(null),
|
||||
item.limits.minRadiusM,
|
||||
).vertices;
|
||||
const old = fit.fitCurveStations(cols(item.nodes), item.limits, 1);
|
||||
const moved = old.map((p, i) => (i === item.index ? item.to : p));
|
||||
const now = fit.fitCurveStations(cols(moved), item.limits, Math.max(1, item.index - 1));
|
||||
return { old_nodes: old, new_nodes: now, old_line: line(old), new_line: line(now) };
|
||||
}
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const routeOf = (line) => {
|
||||
const chainage = [0];
|
||||
for (let i = 1; i < line.length; i += 1)
|
||||
chainage.push(
|
||||
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
|
||||
);
|
||||
return { line, curves: [], chainage, lengthM: chainage[chainage.length - 1] };
|
||||
};
|
||||
|
||||
/** follow 단계를 노선 걸음 차례대로 — 각 걸음 뒤 편집(묶음이 입력에 넣는 것과 같음). */
|
||||
function walk(item) {
|
||||
let stations = [];
|
||||
const stage = grade.createFollowStage({ savedLine: () => null, stationsAt: () => stations });
|
||||
const planline = { policy: item.policy, basePvi: item.base_pvi };
|
||||
let alignmentNow = item.edits;
|
||||
let before = routeOf(item.ref_line);
|
||||
const out = [];
|
||||
for (const step of item.steps) {
|
||||
stations = step.stations;
|
||||
const now = routeOf(step.line);
|
||||
const patch = stage({
|
||||
inputs: { route: { nodes: [] }, pipes: null, structures: null, alignment: alignmentNow },
|
||||
out: { route: now },
|
||||
before: { route: before, planline },
|
||||
});
|
||||
if (patch.alignment) alignmentNow = patch.alignment;
|
||||
out.push(alignmentNow);
|
||||
before = now;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const result = {
|
||||
match: input.match.map((item) => {
|
||||
const fn = carry.lineMatcher(item.old_xy, item.new_xy, item.tol);
|
||||
return item.queries.map((t) => fn(t));
|
||||
}),
|
||||
base: input.base.map((item) =>
|
||||
carry.carryBase(item.old_xy, item.new_xy, item.old_base, item.ground_pvi),
|
||||
),
|
||||
edits: input.edits.map((item) =>
|
||||
carry.carryEdits(item.old_xy, item.new_xy, item.old_base, item.edits, item.stations, item.half),
|
||||
),
|
||||
follow: input.follow.map(walk),
|
||||
snap: (input.snap ?? []).map(snapFit),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
@@ -7,7 +7,8 @@
|
||||
② 관 자리 투영 — `common_util_drainage_pipes.project_pipe_points` ↔
|
||||
`common_util_route_follow.ts` `projectPipePoints`.
|
||||
③ 구조물 따라감 — `common_util_route_follow.follow_structures` ↔ `followStructures`(20 m 상수 같음).
|
||||
④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`.
|
||||
④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`
|
||||
(400 = 같은 자리 · 새 측점 위 · 자세한 규칙은 test_400_profile_carry.py).
|
||||
서버 쪽: 저장 때 좌표 채움(자리가 그대로면 옛 좌표 지킴) · 노선 [확인] 때 구조물 정본이 따라감.
|
||||
"""
|
||||
|
||||
@@ -20,6 +21,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
@@ -29,9 +31,12 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||
carry_profile_edits,
|
||||
carry_base,
|
||||
carry_edits,
|
||||
changed_span,
|
||||
line_matcher,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
@@ -115,6 +120,7 @@ def _carry_case(new_route: list[tuple[float, float]]) -> dict:
|
||||
"base_pvi": base_pvi,
|
||||
"edits": edits,
|
||||
"ground_pvi": alignment["base_pvi"],
|
||||
"stations": [row["chainage_m"] for row in longitudinal["stations"]],
|
||||
}
|
||||
|
||||
|
||||
@@ -335,18 +341,47 @@ def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch):
|
||||
# ── ④ 계획고 옮김 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pts(case, key):
|
||||
return [(p["x"], p["y"]) for p in case[key]]
|
||||
|
||||
|
||||
def _py_carry(case):
|
||||
span = changed_span(
|
||||
[(p["x"], p["y"]) for p in case["old_xy"]], [(p["x"], p["y"]) for p in case["new_xy"]]
|
||||
)
|
||||
old, new = _pts(case, "old_xy"), _pts(case, "new_xy")
|
||||
span = changed_span(old, new)
|
||||
result = (
|
||||
carry_profile_edits(case["base_pvi"], case["edits"], span, case["ground_pvi"])
|
||||
(
|
||||
carry_base(old, new, case["base_pvi"], case["ground_pvi"]),
|
||||
carry_edits(old, new, case["base_pvi"], case["edits"], case["stations"], 10.0),
|
||||
)
|
||||
if span
|
||||
else None
|
||||
)
|
||||
return span, result
|
||||
|
||||
|
||||
def _plan_at(base, edits, s):
|
||||
pvi_s, pvi_z, _ = resolve_pvi(
|
||||
np.array([p["chainage_m"] for p in base]),
|
||||
np.array([p["elevation_m"] for p in base]),
|
||||
edits["station_offsets"],
|
||||
)
|
||||
return float(np.interp(s, pvi_s, pvi_z))
|
||||
|
||||
|
||||
def _kept_plan(case, base_pvi, edits):
|
||||
"""같은 자리 새 측점마다 새 계획고 = 옛 계획고 · 바뀐 자리 측점 목록."""
|
||||
match = line_matcher(_pts(case, "old_xy"), _pts(case, "new_xy"))
|
||||
moved = []
|
||||
for t in case["stations"]:
|
||||
s = match(t)
|
||||
if s is None:
|
||||
moved.append(t)
|
||||
continue
|
||||
want = _plan_at(case["base_pvi"], case["edits"], s)
|
||||
assert _plan_at(base_pvi, edits, t) == pytest.approx(want, abs=1e-3), t
|
||||
return moved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CARRY_CASES)))
|
||||
def test_계획고_옮김_거울(ts, index):
|
||||
span, result = _py_carry(CARRY_CASES[index])
|
||||
@@ -370,33 +405,32 @@ def test_계획고_옮김_거울(ts, index):
|
||||
assert edits == ts_result["edits"]
|
||||
|
||||
|
||||
def test_가운데를_고치면_앞은_그대로_뒤는_밂():
|
||||
span, (base_pvi, edits) = _py_carry(CARRY_CASES[0])
|
||||
def test_가운데를_고치면_앞은_그대로_뒤는_같은자리_계획고():
|
||||
case = CARRY_CASES[0]
|
||||
span, (base_pvi, edits) = _py_carry(case)
|
||||
assert span["start_fixed"] and span["end_fixed"]
|
||||
delta = span["delta_m"]
|
||||
assert span["start_m"] == pytest.approx(math.dist(OLD_ROUTE[0], OLD_ROUTE[1]))
|
||||
# 앞 편집 그대로 · 안(90.5) 버림 · 뒤(180 · 207.6) 길이 차만큼
|
||||
# 앞 편집 그대로 · 안(90.5) 버림 · 앞 R 그대로 · 같은 자리 새 측점 계획고 = 옛 계획고
|
||||
assert edits["station_offsets"]["20.000"] == 0.5
|
||||
assert "90.500" not in edits["station_offsets"]
|
||||
assert edits["station_offsets"][f"{180.0 + delta:.3f}"] == 0.2
|
||||
assert edits["curve_radii"] == {"40.000": 300.0, f"{150.0 + delta:.3f}": 250.0}
|
||||
old = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["base_pvi"]}
|
||||
assert edits["curve_radii"]["40.000"] == 300.0
|
||||
moved = _kept_plan(case, base_pvi, edits)
|
||||
# 바뀐 자리는 새 지반 첫 계획선 꼭짓점 · 편집 없음
|
||||
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in case["ground_pvi"]}
|
||||
got = {round(p["chainage_m"], 3): p["elevation_m"] for p in base_pvi}
|
||||
assert got[20.0] == old[20.0] and got[round(180.0 + delta, 3)] == old[180.0]
|
||||
# 바뀐 구간 안은 새 지반 첫 계획선 꼭짓점
|
||||
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["ground_pvi"]}
|
||||
inside = [s for s in got if span["start_m"] + 1e-6 < s < span["new_end_m"] - 1e-6]
|
||||
assert inside and all(got[s] == ground[s] for s in inside)
|
||||
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + delta, abs=1e-3)
|
||||
assert moved and all(got[round(t, 3)] == ground[round(t, 3)] for t in moved)
|
||||
assert not {f"{t:.3f}" for t in moved} & set(edits["station_offsets"])
|
||||
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + span["delta_m"], abs=1e-3)
|
||||
|
||||
|
||||
def test_끝을_고치면_끝_꼭짓점은_새_지반():
|
||||
span, (base_pvi, edits) = _py_carry(CARRY_CASES[1])
|
||||
case = CARRY_CASES[1]
|
||||
span, (base_pvi, edits) = _py_carry(case)
|
||||
assert not span["end_fixed"]
|
||||
assert f"{L_OLD:.3f}" not in edits["station_offsets"] # 바뀐 끝 편집은 버림
|
||||
assert edits["station_offsets"]["90.500"] == -0.3 # 앞(바뀐 구간 앞) 편집 그대로
|
||||
ground_end = CARRY_CASES[1]["ground_pvi"][-1]
|
||||
_kept_plan(case, base_pvi, edits) # 앞(90.5 편집 둘레) 계획고 그대로
|
||||
ground_end = case["ground_pvi"][-1]
|
||||
assert base_pvi[-1] == {
|
||||
"chainage_m": round(ground_end["chainage_m"], 6),
|
||||
"chainage_m": ground_end["chainage_m"],
|
||||
"elevation_m": ground_end["elevation_m"],
|
||||
}
|
||||
|
||||
@@ -522,7 +522,7 @@ def _longitudinal(total: float) -> dict:
|
||||
return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]}
|
||||
|
||||
|
||||
def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
|
||||
def test_계획고_옮김은_앞_그대로_뒤_같은자리(tmp_path, monkeypatch):
|
||||
new_xy = [list(p) for p in NEW]
|
||||
total = float(vertices_of(NEW)[-1].chainage_m)
|
||||
old = _longitudinal(400.0)
|
||||
@@ -548,10 +548,12 @@ def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
|
||||
)
|
||||
)
|
||||
edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"]
|
||||
delta = total - 400.0
|
||||
assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로
|
||||
assert "200.000" not in edits # 바뀐 구간 안 — 새 지반
|
||||
assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂
|
||||
# 뒤(옛 380 둘레) — 같은 자리 새 측점 위 편집으로 이어받음(400 · 길이 차만큼 민 글자 키 아님)
|
||||
stations = {f"{row['chainage_m']:.3f}" for row in _longitudinal(total)["stations"]}
|
||||
behind = [key for key in edits if float(key) > 300.0]
|
||||
assert behind and set(behind) <= stations
|
||||
|
||||
|
||||
def test_저장_확정이_옛_횡단_파일을_지움(tmp_path):
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
"""349-0v 손으로 고친 노선 [저장] — 미리보기 계획고 = 저장 뒤 계획고(바뀐 구간 거울).
|
||||
|
||||
화면 planline 단계: 옛 = 저장 계획 폴리라인(`read_route_plan` planned) · 새 = `buildEditedPolyline`(노드
|
||||
초안) → `changedSpan` → `carryProfileEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 CSV · 새 =
|
||||
`_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_profile_edits`. 두 길이 같은 구간 · 같은
|
||||
계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 앞머리가 일찍 갈렸음).
|
||||
초안) → `changedSpan` → `carryBase` · `carryEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인
|
||||
CSV · 새 = `_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_base` · `carry_edits`(400).
|
||||
두 길이 같은 구간 · 같은 계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라
|
||||
앞머리가 일찍 갈렸음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,7 +25,8 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||
carry_profile_edits,
|
||||
carry_base,
|
||||
carry_edits,
|
||||
changed_span,
|
||||
)
|
||||
|
||||
@@ -80,6 +82,7 @@ def world(tmp_path_factory):
|
||||
"base_pvi": base_pvi,
|
||||
"edits": edits,
|
||||
"ground_pvi": ground_pvi,
|
||||
"stations": [p["chainage_m"] for p in ground_pvi],
|
||||
}
|
||||
source = tmp / "input.json"
|
||||
source.write_text(json.dumps(payload), encoding="utf-8")
|
||||
@@ -119,11 +122,18 @@ def test_옮긴_계획고_화면_서버_같음(world):
|
||||
span = changed_span(
|
||||
[tuple(p) for p in world["old_line"]], [tuple(p) for p in world["new_line_py"]]
|
||||
)
|
||||
base, edits = carry_profile_edits(world["base_pvi"], world["edits"], span, world["ground_pvi"])
|
||||
old, new = world["old_line"], world["new_line_py"]
|
||||
base = carry_base(old, new, world["base_pvi"], world["ground_pvi"])
|
||||
edits = carry_edits(old, new, world["base_pvi"], world["edits"], world["stations"], 20.0)
|
||||
assert span is not None
|
||||
ts = world["ts"]["result"]
|
||||
flat = [v for n in base for v in (n["chainage_m"], n["elevation_m"])]
|
||||
assert flat == pytest.approx(
|
||||
[v for n in ts["basePvi"] for v in (n["chainage_m"], n["elevation_m"])], abs=1e-3
|
||||
) # 서버 새 선 = CSV 0.1 mm 반올림 · 화면 = 원값
|
||||
assert edits == ts["edits"]
|
||||
assert edits["station_offsets"]["150.000"] == 0.8 # 바뀐 구간 앞 사용자 계획고 그대로
|
||||
for field in ("station_offsets", "curve_radii"):
|
||||
assert edits[field].keys() == ts["edits"][field].keys()
|
||||
for key, value in edits[field].items():
|
||||
assert value == pytest.approx(ts["edits"][field][key], abs=1e-3)
|
||||
# 바뀐 구간 앞 사용자 계획고(150) = 둘레 새 측점(120 · 160) 편집으로 그대로
|
||||
assert {"120.000", "160.000"} <= set(edits["station_offsets"])
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""400 평면 고친 뒤 종단 편집 이어받기 — 같은 자리(옆 1 m · 방향 30° 안)는 옛 계획고를 새 측점 위에.
|
||||
|
||||
거울(파이썬 = 서버 [저장] 정본 · TS = 브라우저 즉시 · 같은 값):
|
||||
`B05_Profile_Engine_Grade_Carry` line_matcher · carry_base · carry_edits ↔
|
||||
`logic/B05_Profile_Grade_Carry.ts` lineMatcher · carryBase · carryEdits.
|
||||
묶음 follow(`logic/B05_Profile_Pipeline_Grade.ts`) — 걸음 수와 무관(기준 한 벌에서 한 번) ·
|
||||
되돌린 노선 = 옛 편집.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
|
||||
SAME_LINE_TOL_M,
|
||||
carry_base,
|
||||
carry_edits,
|
||||
line_matcher,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
INTERVAL = 20.0
|
||||
HALF = INTERVAL / 2
|
||||
|
||||
|
||||
# ── 입력 만들기 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _dense(nodes: list[tuple[float, float]], step: float = 2.0) -> list[list[float]]:
|
||||
"""꺾은선을 `step` m 안팎 점으로(그린 노선처럼 정점이 많게)."""
|
||||
out = [list(nodes[0])]
|
||||
for (x0, y0), (x1, y1) in zip(nodes, nodes[1:]):
|
||||
n = max(1, int(math.ceil(math.dist((x0, y0), (x1, y1)) / step)))
|
||||
out += [[x0 + (x1 - x0) * k / n, y0 + (y1 - y0) * k / n] for k in range(1, n + 1)]
|
||||
return out
|
||||
|
||||
|
||||
def _length(line) -> float:
|
||||
return sum(math.dist(a, b) for a, b in zip(line, line[1:]))
|
||||
|
||||
|
||||
def _stations(line) -> list[float]:
|
||||
total = _length(line)
|
||||
grid = [round(k * INTERVAL, 3) for k in range(int(total // INTERVAL) + 1)]
|
||||
return grid + ([round(total, 3)] if total - grid[-1] > 1e-6 else [])
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 37.0)
|
||||
|
||||
|
||||
def _ground_pvi(line) -> list[dict]:
|
||||
"""새 노선 지반 첫 계획선 꼴(측점마다 지반 · 옛 계획고와 다르게 +3 m)."""
|
||||
return [{"chainage_m": s, "elevation_m": round(_ground(s) + 3.0, 6)} for s in _stations(line)]
|
||||
|
||||
|
||||
OLD_NODES = [(0.0, 0.0), (200.0, 0.0), (400.0, 100.0), (600.0, 100.0), (800.0, 0.0)]
|
||||
OLD = _dense(OLD_NODES)
|
||||
OLD_BASE = [{"chainage_m": s, "elevation_m": round(_ground(s), 6)} for s in _stations(OLD)]
|
||||
|
||||
|
||||
def _old_edits() -> dict:
|
||||
"""직선화(40 ~ 120 사이 측점) · ▲ 1 m(300) · 종단곡선 R(300) · 자유지정 꺾임(520 · 540)."""
|
||||
base = {n["chainage_m"]: n["elevation_m"] for n in OLD_BASE}
|
||||
z40, z120 = base[40.0], base[120.0]
|
||||
offsets = {}
|
||||
for s in (60.0, 80.0, 100.0):
|
||||
offsets[f"{s:.3f}"] = round(z40 + (z120 - z40) * (s - 40) / 80 - base[s], 6)
|
||||
offsets["300.000"] = 1.0
|
||||
offsets["520.000"] = -0.8
|
||||
offsets["540.000"] = -1.2
|
||||
return {"station_offsets": offsets, "curve_radii": {"300.000": 400.0, "520.000": 250.0}}
|
||||
|
||||
|
||||
OLD_EDITS = _old_edits()
|
||||
|
||||
|
||||
def _moved(index: int, to: tuple[float, float]) -> list[list[float]]:
|
||||
nodes = list(OLD_NODES)
|
||||
nodes[index] = to
|
||||
return _dense(nodes)
|
||||
|
||||
|
||||
CASES = {
|
||||
# 먼 교점(끝 앞) 하나 옮김 — 앞쪽 편집 그대로
|
||||
"far": _moved(3, (620.0, 130.0)),
|
||||
# 시점을 뒤로 15 m 늘림 — 같은 선 · 누가거리만 +15(새 측점은 옛 측점 사이)
|
||||
"start": _dense([(-15.0, 0.0)] + OLD_NODES[1:]),
|
||||
# 끝을 같은 방향으로 늘림 — 옛 끝까지 그대로 · 넘는 자리만 지반
|
||||
"end": _dense(OLD_NODES[:-1] + [(840.0, -20.0)]),
|
||||
# 측점 스냅처럼 뒤 교점이 옆으로 0.6 m(같은 자리) · 1.6 m(바뀐 자리) 밀림
|
||||
"snap": _dense(OLD_NODES[:2] + [(400.0, 100.6), (600.0, 101.6), (800.0, 1.6)]),
|
||||
"same": OLD,
|
||||
}
|
||||
|
||||
|
||||
def _xy(line):
|
||||
return [{"x": x, "y": y} for x, y in line]
|
||||
|
||||
|
||||
def _plan(base: list[dict], edits: dict) -> tuple[np.ndarray, np.ndarray]:
|
||||
s = np.array([n["chainage_m"] for n in base], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in base], dtype=np.float64)
|
||||
pvi_s, pvi_z, _ = resolve_pvi(s, z, edits["station_offsets"])
|
||||
return pvi_s, pvi_z
|
||||
|
||||
|
||||
def _py(name: str) -> tuple[list[dict], dict]:
|
||||
new = CASES[name]
|
||||
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
|
||||
edits = carry_edits(OLD, new, OLD_BASE, OLD_EDITS, _stations(new), HALF)
|
||||
return base, edits
|
||||
|
||||
|
||||
# ── TS 한 번 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
FOLLOW_STEPS = [
|
||||
# 기준 → 먼 교점 옮김 → 더 옮김 · 한 번에 옮김과 같아야
|
||||
[("far1", _moved(3, (610.0, 115.0))), ("far2", CASES["far"])],
|
||||
[("far2", CASES["far"])],
|
||||
# 바뀐 자리를 거쳐 되돌림 — 옛 편집 그대로
|
||||
[("mid", _moved(4, (830.0, 30.0))), ("back", OLD)],
|
||||
]
|
||||
|
||||
|
||||
# 측점 스냅 켠 노선 — 앞 교점 하나 옮기면 뒤 교점이 모두 MC 맞춤으로 밀림(StationFit)
|
||||
SNAP = {
|
||||
"nodes": [[0, 0], [150, 30], [300, 0], [450, 40], [600, 10], [750, 50], [900, 20], [1050, 40]],
|
||||
"index": 1,
|
||||
"to": [150.0, 37.0],
|
||||
"limits": {"minRadiusM": 60.0, "limitRadiusM": 0.0, "limitArcM": 0.0, "intervalM": INTERVAL},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts(tmp_path_factory) -> dict:
|
||||
queries = [0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6]
|
||||
payload = {
|
||||
"match": [
|
||||
{"old_xy": _xy(OLD), "new_xy": _xy(line), "tol": SAME_LINE_TOL_M, "queries": queries}
|
||||
for line in CASES.values()
|
||||
],
|
||||
"base": [
|
||||
{
|
||||
"old_xy": _xy(OLD),
|
||||
"new_xy": _xy(line),
|
||||
"old_base": OLD_BASE,
|
||||
"ground_pvi": _ground_pvi(line),
|
||||
}
|
||||
for line in CASES.values()
|
||||
],
|
||||
"edits": [
|
||||
{
|
||||
"old_xy": _xy(OLD),
|
||||
"new_xy": _xy(line),
|
||||
"old_base": OLD_BASE,
|
||||
"edits": OLD_EDITS,
|
||||
"stations": _stations(line),
|
||||
"half": HALF,
|
||||
}
|
||||
for line in CASES.values()
|
||||
],
|
||||
"follow": [
|
||||
{
|
||||
"ref_line": OLD,
|
||||
"policy": {"station_interval_m": INTERVAL},
|
||||
"base_pvi": OLD_BASE,
|
||||
"edits": OLD_EDITS,
|
||||
"steps": [{"line": line, "stations": _stations(line)} for _, line in steps],
|
||||
}
|
||||
for steps in FOLLOW_STEPS
|
||||
],
|
||||
"snap": [SNAP],
|
||||
}
|
||||
source = tmp_path_factory.mktemp("s400") / "in.json"
|
||||
source.write_text(json.dumps(payload), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_400_profile_carry.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
# ── 거울 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(CASES)))
|
||||
def test_거울_같은자리_꼭짓점_편집(ts, index):
|
||||
name, line = list(CASES.items())[index]
|
||||
match = line_matcher(OLD, line)
|
||||
for t, got in zip([0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6], ts["match"][index]):
|
||||
want = match(t)
|
||||
assert (want is None) == (got is None), (name, t)
|
||||
if want is not None:
|
||||
assert want == pytest.approx(got, abs=1e-9)
|
||||
base, edits = _py(name)
|
||||
assert len(base) == len(ts["base"][index])
|
||||
for a, b in zip(base, ts["base"][index]):
|
||||
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-9)
|
||||
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
|
||||
got = ts["edits"][index]
|
||||
for field in ("station_offsets", "curve_radii"):
|
||||
assert edits[field].keys() == got[field].keys(), (name, field)
|
||||
for key, value in edits[field].items():
|
||||
assert value == pytest.approx(got[field][key], abs=1e-6)
|
||||
|
||||
|
||||
# ── 규칙 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _same_place_plan(name: str):
|
||||
"""같은 자리 새 측점마다 (새 계획고, 옛 계획고) · 바뀐 자리 측점."""
|
||||
new = CASES[name]
|
||||
base, edits = _py(name)
|
||||
match = line_matcher(OLD, new)
|
||||
old_s, old_z = _plan(OLD_BASE, OLD_EDITS)
|
||||
new_s, new_z = _plan(base, edits)
|
||||
kept, moved = [], []
|
||||
for t in _stations(new):
|
||||
s = match(t)
|
||||
if s is None:
|
||||
moved.append(t)
|
||||
else:
|
||||
kept.append((t, float(np.interp(t, new_s, new_z)), float(np.interp(s, old_s, old_z))))
|
||||
return kept, moved, base, edits
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["far", "start", "end", "snap", "same"])
|
||||
def test_같은자리_계획고_그대로(name):
|
||||
kept, _, _, _ = _same_place_plan(name)
|
||||
assert kept
|
||||
for t, new_z, old_z in kept:
|
||||
# 옛 자동 꼭짓점 0.006 m 안 측점은 그 꼭짓점 위로 봄 — 기울기 차 × 0.006 m 안
|
||||
assert new_z == pytest.approx(old_z, abs=1e-3), (name, t)
|
||||
|
||||
|
||||
def test_먼교점_옮김_앞쪽_편집_누가거리_그대로_바뀐자리만_지반():
|
||||
kept, moved, base, edits = _same_place_plan("far")
|
||||
# 앞쪽(같은 선 · 같은 측점) 편집 글자 그대로
|
||||
for key in ("60.000", "80.000", "100.000", "300.000"):
|
||||
assert edits["station_offsets"][key] == pytest.approx(OLD_EDITS["station_offsets"][key])
|
||||
assert edits["curve_radii"]["300.000"] == 400.0
|
||||
# 바뀐 자리 = 옮긴 교점 둘레 · 그 측점은 지반 첫 계획선 · 편집 없음(옛 520 · 540 자유지정 버림)
|
||||
assert moved and min(moved) > 423.0
|
||||
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(CASES["far"])}
|
||||
by = {n["chainage_m"]: n["elevation_m"] for n in base}
|
||||
assert all(by[t] == ground[t] for t in moved)
|
||||
assert not any(float(k) in moved for k in edits["station_offsets"])
|
||||
|
||||
|
||||
def test_시점_늘림_누가거리_투영_새측점_위():
|
||||
kept, moved, _, edits = _same_place_plan("start")
|
||||
assert not moved or max(moved) < 1.0
|
||||
stations = set(_stations(CASES["start"]))
|
||||
assert edits["station_offsets"] and all(float(k) in stations for k in edits["station_offsets"])
|
||||
assert all(float(k) in stations for k in edits["curve_radii"])
|
||||
# ▲(옛 300) 의 R 은 옛 300 = 새 315 에서 가장 가까운 새 측점(320 · 옛 305)
|
||||
assert edits["curve_radii"].get("320.000") == 400.0
|
||||
|
||||
|
||||
def test_끝_늘림_옛끝까지_이어받고_넘는자리만_지반():
|
||||
kept, moved, base, _ = _same_place_plan("end")
|
||||
old_len = _length(OLD)
|
||||
assert moved and min(moved) > old_len
|
||||
assert max(t for t, _, _ in kept) > old_len - INTERVAL
|
||||
|
||||
|
||||
def test_스냅처럼_옆으로_밀림_1m_안_이어받고_넘으면_다시():
|
||||
match = line_matcher(OLD, CASES["snap"])
|
||||
assert match(410.0) is not None # 0.6 m 쪽
|
||||
assert match(650.0) is None # 1.6 m 쪽
|
||||
_, _, _, edits = _same_place_plan("snap")
|
||||
assert "300.000" in edits["station_offsets"]
|
||||
|
||||
|
||||
def test_종단_안_고친_프로젝트는_편집_없음_꼭짓점은_옛것_또는_지반():
|
||||
empty = {"station_offsets": {}, "curve_radii": {}}
|
||||
new = CASES["far"]
|
||||
assert carry_edits(OLD, new, OLD_BASE, empty, _stations(new), HALF) == empty
|
||||
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
|
||||
old_s = [n["chainage_m"] for n in OLD_BASE]
|
||||
old_z = [n["elevation_m"] for n in OLD_BASE]
|
||||
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(new)}
|
||||
match = line_matcher(OLD, new)
|
||||
for node in base:
|
||||
t = node["chainage_m"]
|
||||
s = match(t)
|
||||
want = ground[t] if s is None else round(float(np.interp(s, old_s, old_z)), 6)
|
||||
assert node["elevation_m"] == pytest.approx(want, abs=1e-9)
|
||||
|
||||
|
||||
def test_엇갈려_지나는_선은_같은자리_아님():
|
||||
crossing = _dense([(100.0, -50.0), (100.0, 50.0)])
|
||||
assert line_matcher(OLD, crossing)(50.0) is None
|
||||
reverse = OLD[::-1]
|
||||
assert line_matcher(OLD, reverse)(100.0) is None
|
||||
|
||||
|
||||
# ── 묶음 follow ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_follow_걸음수와_무관_서버와_같은값(ts):
|
||||
stepwise, direct, back = ts["follow"]
|
||||
assert stepwise[-1] == direct[-1]
|
||||
_, edits = _py("far")
|
||||
got = direct[-1]
|
||||
assert edits["station_offsets"].keys() == got["station_offsets"].keys()
|
||||
for key, value in edits["station_offsets"].items():
|
||||
assert value == pytest.approx(got["station_offsets"][key], abs=1e-6)
|
||||
assert edits["curve_radii"] == got["curve_radii"]
|
||||
|
||||
|
||||
def test_follow_바뀐자리_거쳐_되돌리면_옛편집(ts):
|
||||
_, _, back = ts["follow"]
|
||||
assert "520.000" in back[0]["station_offsets"] # 끝 교점 옮김 — 앞쪽(먼) 편집은 남음
|
||||
assert back[-1] == OLD_EDITS
|
||||
|
||||
|
||||
def test_측점스냅_켠_노선_뒤_교점_밀려도_같은자리_편집_이어받음(ts):
|
||||
got = ts["snap"][0]
|
||||
old_line, new_line = got["old_line"], got["new_line"]
|
||||
shifts = [math.dist(a, b) for a, b in zip(got["old_nodes"][2:], got["new_nodes"][2:])]
|
||||
assert max(shifts) > 0.5 # 뒤 교점이 정말 밀림 — 1 mm 정점 비교로는 뒤쪽 전부 「바뀐 구간」
|
||||
stations = _stations(old_line)
|
||||
base = [{"chainage_m": t, "elevation_m": round(_ground(t), 6)} for t in stations]
|
||||
far = [t for t in stations if 600.0 <= t <= 900.0]
|
||||
offsets = {f"{t:.3f}": round(0.5 + 0.001 * t, 6) for t in far} # 뒤쪽 직선화 · ▲ 몫
|
||||
edits = {"station_offsets": offsets, "curve_radii": {}}
|
||||
new_stations = _stations(new_line)
|
||||
new_base = carry_base(old_line, new_line, base, _ground_pvi(new_line))
|
||||
new_edits = carry_edits(old_line, new_line, base, edits, new_stations, HALF)
|
||||
match = line_matcher(old_line, new_line)
|
||||
old_s, old_z = _plan(base, edits)
|
||||
new_s, new_z = _plan(new_base, new_edits)
|
||||
carried = [t for t in new_stations if f"{t:.3f}" in new_edits["station_offsets"]]
|
||||
assert (
|
||||
len(carried) >= len(far) // 2
|
||||
) # 뒤쪽 편집 절반 넘게 이어받음(나머지 = 1 m 넘게 밀린 곡선 둘레)
|
||||
for t in carried:
|
||||
s = match(t)
|
||||
assert float(np.interp(t, new_s, new_z)) == pytest.approx(
|
||||
float(np.interp(s, old_s, old_z)), abs=1e-3
|
||||
)
|
||||
Reference in New Issue
Block a user