- 새 A00_Common/map/logic/map_guide_route.ts — 지점 차례 구간마다 A* · 20 m 걸음 56 방향 · ±14 % 막음 · 급경사 35°+ · 기울기 · 꺾임 벌점 · 가중 어림 × 3 · 상자 두 점 + 여유(끝에 닿았을 때만 두 배 한 번) · 펼침 30 만 + 450 ms 상한 · 첫 · 끝 걸음 정확 · 1 m 격자 2 칸 솎음 · 못 닿음 = 가장 가까이 간 데까지 - 화면 가이드 전용 · 파이썬 짝 없음 · 저장 없음 - 지킴 시험 test_636_guide_route — 평지 · 지그재그 · 못 닿음 · 지점 셋 · 급경사 벌점 · 1 km · 솎음 · 지형 밖 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.5 KiB
Python
188 lines
6.5 KiB
Python
"""예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — `A00_Common/map/logic/map_guide_route.ts`.
|
|
|
|
2 m 격자 A*(20 m 걸음 · ±14 % 막음 · 급경사 벌점 · 꺾임 벌점 · 펼침 상한).
|
|
평지 · 높이 차 지그재그(모든 걸음 ≤ 상한 · 첫 · 끝 걸음 포함) · 못 닿음 ≤ 0.5 s · 지점 셋 ·
|
|
급경사 벌점 효과 · 1 km ≤ 0.3 s · 1 m 격자 솎음 · 지형 밖.
|
|
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
|
|
|
|
./venv/Scripts/python.exe -m pytest resources/tester/test_636_guide_route.py -q
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_guide_route.ts"
|
|
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
GMAX = 0.14
|
|
|
|
_RUNNER = """
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { guideRoute } from "./A00_Common/map/logic/map_guide_route.js";
|
|
// 산 꼴 — 급경사 35°+ 약 2 할 · 동쪽으로 12 % 오름
|
|
const hills = (x, y) =>
|
|
10 * Math.sin(x / 37) * Math.cos(y / 53) + 6.25 * Math.sin((x + 2 * y) / 19) + 0.12 * x;
|
|
const TERRAIN = {
|
|
flat: () => 100,
|
|
plane: (x) => 0.3 * x,
|
|
hills,
|
|
cliff: (x, y) => hills(x, y) + (x >= 0 ? 500 : 0),
|
|
hole: (x, y) => (Math.hypot(x, y) < 50 ? NaN : 0),
|
|
};
|
|
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
const out = {};
|
|
for (const [key, c] of Object.entries(cases)) {
|
|
const terrain = { heightAt: TERRAIN[c.terrain], cell: c.cell ?? 2 };
|
|
const runs = [];
|
|
for (let k = 0; k < (c.repeat ?? 1); k++)
|
|
runs.push(guideRoute(c.points, terrain, c.options ?? {}));
|
|
out[key] = { ...runs[runs.length - 1], allMs: runs.map((r) => r.ms) };
|
|
}
|
|
writeFileSync(process.argv[3], JSON.stringify(out));
|
|
"""
|
|
|
|
CASES = {
|
|
"flat": {"terrain": "flat", "points": [[0.7, 0.3], [500.4, 3.1]]},
|
|
"zigzag": {"terrain": "plane", "points": [[0.7, 0.3], [300.9, 1.3]]},
|
|
"cliff": {"terrain": "cliff", "points": [[-500.0, 0.0], [500.0, 0.0]]},
|
|
"three": {"terrain": "hills", "points": [[0.0, 0.0], [400.0, 300.0], [700.0, -100.0]]},
|
|
"steep0": {
|
|
"terrain": "hills",
|
|
"points": [[0.0, 0.0], [600.0, 400.0]],
|
|
"options": {"weights": {"steep": 0}},
|
|
},
|
|
"steep3": {"terrain": "hills", "points": [[0.0, 0.0], [600.0, 400.0]]},
|
|
"km": {"terrain": "hills", "points": [[0.0, 0.0], [800.0, 600.0]], "repeat": 3},
|
|
"lidar": {"terrain": "hills", "cell": 1, "points": [[0.0, 0.0], [300.0, 200.0]]},
|
|
"outside": {"terrain": "hole", "points": [[0.0, 0.0], [300.0, 0.0]]},
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
|
tmp = tmp_path_factory.mktemp("guide_route")
|
|
out = tmp / "js"
|
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
[
|
|
"node",
|
|
str(TSC),
|
|
str(TS_FILE),
|
|
"--outDir",
|
|
str(out),
|
|
"--rootDir",
|
|
str(ROOT),
|
|
"--module",
|
|
"esnext",
|
|
"--target",
|
|
"es2022",
|
|
"--moduleResolution",
|
|
"bundler",
|
|
"--ignoreConfig",
|
|
],
|
|
cwd=str(ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
for emitted in out.rglob("*.js"):
|
|
emitted.write_text(
|
|
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
|
encoding="utf-8",
|
|
)
|
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
|
job, result = tmp / "job.json", tmp / "out.json"
|
|
job.write_text(json.dumps(CASES), encoding="utf-8")
|
|
subprocess.run( # noqa: S603
|
|
["node", str(out / "runner.mjs"), str(job), str(result)],
|
|
cwd=str(ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return json.loads(result.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _grades(vertices: list) -> list[float]:
|
|
return [
|
|
abs(b[2] - a[2]) / math.hypot(b[0] - a[0], b[1] - a[1])
|
|
for a, b in zip(vertices, vertices[1:])
|
|
]
|
|
|
|
|
|
def _check_ends(seg: dict, start: list, goal: list) -> None:
|
|
assert seg["vertices"][0][:2] == start
|
|
assert seg["vertices"][-1][:2] == goal
|
|
|
|
|
|
def test_평지_곧게(r):
|
|
(seg,) = r["flat"]["segments"]
|
|
assert seg["reached"]
|
|
_check_ends(seg, *CASES["flat"]["points"])
|
|
span = math.dist(*CASES["flat"]["points"])
|
|
assert seg["length"] <= span * 1.1
|
|
assert seg["maxGrade"] == 0 and seg["steepRatio"] == 0
|
|
|
|
|
|
def test_높이_차_지그재그_모든_걸음_상한_안(r):
|
|
(seg,) = r["zigzag"]["segments"]
|
|
assert seg["reached"]
|
|
_check_ends(seg, *CASES["zigzag"]["points"])
|
|
grades = _grades(seg["vertices"])
|
|
assert max(grades) <= GMAX + 1e-9 # 첫 · 끝 걸음 포함
|
|
assert seg["maxGrade"] == pytest.approx(max(grades))
|
|
assert seg["length"] >= 0.3 * 300 / GMAX # 높이 차 90 m 를 14 % 로
|
|
steps = [math.dist(a[:2], b[:2]) for a, b in zip(seg["vertices"], seg["vertices"][1:])]
|
|
assert all(19 <= s <= 21.5 for s in steps[:-1]) # 꼭짓점 그대로(끝 걸음만 짧을 수 있음)
|
|
|
|
|
|
def test_못_닿음_가장_가까이_간_데까지_빠르게(r):
|
|
(seg,) = r["cliff"]["segments"]
|
|
assert not seg["reached"]
|
|
assert seg["vertices"][0][:2] == CASES["cliff"]["points"][0]
|
|
assert -40 <= seg["vertices"][-1][0] < 0 # 벼랑 앞까지
|
|
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
|
|
assert r["cliff"]["ms"] <= 500
|
|
|
|
|
|
def test_지점_셋_구간마다_이어짐(r):
|
|
points = CASES["three"]["points"]
|
|
segs = r["three"]["segments"]
|
|
assert len(segs) == 2
|
|
for seg, a, b in zip(segs, points, points[1:]):
|
|
assert seg["reached"]
|
|
_check_ends(seg, a, b)
|
|
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
|
|
assert segs[0]["vertices"][-1] == segs[1]["vertices"][0]
|
|
|
|
|
|
def test_급경사_벌점이_급경사_지남을_줄임(r):
|
|
off = r["steep0"]["segments"][0]
|
|
on = r["steep3"]["segments"][0]
|
|
assert off["reached"] and on["reached"]
|
|
assert on["steepRatio"] < off["steepRatio"]
|
|
|
|
|
|
def test_1_km_0_3_초_안(r):
|
|
(seg,) = r["km"]["segments"]
|
|
assert seg["reached"]
|
|
assert max(_grades(seg["vertices"])) <= GMAX + 1e-9
|
|
assert min(r["km"]["allMs"]) <= 300
|
|
|
|
|
|
def test_1_m_격자는_2_m_로_솎음(r):
|
|
(seg,) = r["lidar"]["segments"]
|
|
assert seg["reached"]
|
|
inner = seg["vertices"][1:-1]
|
|
assert inner and all(v[0] % 2 == 0 and v[1] % 2 == 0 for v in inner)
|
|
|
|
|
|
def test_지형_밖_지점은_못_닿음(r):
|
|
(seg,) = r["outside"]["segments"]
|
|
assert not seg["reached"]
|
|
assert len(seg["vertices"]) == 1
|