Files
Aislo/resources/tester/test_344_3_pipeline_stations_mirror.py
T
eomsangdonandClaude Opus 5.5 92163bda3e feat(설계묶음): 344-3 · 346-1 stations 단계 — 그린 노선 + 관 · 구조물 → 측점 한 벌(열쇠 · 종류 · 틀 · 방위각 · 반경 · 바깥쪽 · 확폭) · 서버 generate_sections 짝
- B05_Profile/logic/B05_Profile_Sections_Stations.ts 새 — buildStations(격자 · 스냅 · 심는 측점 병합 = 이미 있는 짝 그대로) · 평면 반경 · 확폭 짝(_plan_radii · _curve_widenings · _design_curve_widenings) · stationKey · stationText(0.01 m)
- A00_Common/design/design_pipeline_stations.ts 새 — createStationsStage · 범위 밖(± 20 m) 측점은 앞 객체 그대로
- Engine_Sections_Core.py 머리에 짝 표시
- 거울 시험 test_344_3_pipeline_stations_mirror 7 건(곡선표 · 스냅 · 곡선표 없음 서버와 같음 · 열쇠 · 한 점 옮김 앞 구간 열쇠 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 11:29:27 +09:00

262 lines
9.6 KiB
Python

"""PLAN 344-3 · 346-1 — 계산 묶음 stations 단계 거울(파이썬 ↔ TS).
· 서버 `generate_sections` 측점 목록 = TS `buildStations`(`B05_Profile/logic/B05_Profile_Sections_Stations.ts`)
— 누가거리 · 종류 · 열쇠 · 자리 · 방위각 · 평면 반경 · 바깥쪽 · 확폭(곡선표 · 스냅 · 곡선표 없음 셋)
· 상수 = config_system_design · Engine_Sections_Core 같은 값
· 노선 한 점 옮김 뒤 앞 구간 측점 열쇠 그대로 · 범위 밖은 앞 객체 그대로(단계 `design_pipeline_stations.ts`)
짝: `helper_344_3_pipeline_stations.cjs`(실제 TS 를 Node 로 돌림).
"""
from __future__ import annotations
import json
import math
import subprocess
import sys
import tempfile
from types import SimpleNamespace
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 import B05_Profile_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402
from common_util.common_util_structure_station import plan_stations # noqa: E402
from config import config_system_design as cfg # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
_TOL = 1.5e-6 # 두 쪽 다 소수 6자리 반올림
PIPES = [
{"id": "p57", "chainage_m": 57.0, "source": "user"},
{"id": "p140", "chainage_m": 140.0, "source": "user"}, # 격자 위 — 관 측점이 위
{
"id": "p-rev",
"chainage_m": 250.0,
"source": "user",
"facility": "revetment",
"start_m": 245.0,
"end_m": 260.0,
},
{"id": "p333", "chainage_m": 333.0, "source": "user"},
]
STRUCTURES = [
{
"structure_id": "s-wall",
"type_id": "retaining_wall",
"placement": "interval",
"chainage_m": 410.0,
"start_m": 400.0,
"end_m": 430.0,
},
]
class _Flat:
"""평지 표본기 — 측점 부분만 대조(지반은 344-1 거울 몫)."""
def sample_xy(self, xy):
xy = np.asarray(xy)
return np.zeros(len(xy)), np.ones(len(xy), dtype=bool)
def _route(nodes, radii):
"""꺾임점마다 원호(반경) — 그린 노선 · 곡선표(시작 · 꼭짓점 · 끝 · 반경)."""
line = [list(nodes[0])]
curves = []
for i in range(1, len(nodes) - 1):
p, b, n = (np.asarray(v, dtype=float) for v in (nodes[i - 1], nodes[i], nodes[i + 1]))
u = (b - p) / np.linalg.norm(b - p)
v = (n - b) / np.linalg.norm(n - b)
delta = math.acos(max(-1.0, min(1.0, float(u @ v))))
r = radii[i - 1]
t = r * math.tan(delta / 2)
bc, ec = b - u * t, b + v * t
turn = 1.0 if u[0] * v[1] - u[1] * v[0] > 0 else -1.0
center = bc + turn * r * np.array([-u[1], u[0]])
a0 = math.atan2(*(bc - center)[::-1])
for k in range(21):
a = a0 + turn * delta * k / 20
line.append([float(center[0] + r * math.cos(a)), float(center[1] + r * math.sin(a))])
curves.append({"start": bc.tolist(), "apex": b.tolist(), "end": ec.tolist(), "radius_m": r})
line.append(list(nodes[-1]))
return line, curves
NODES = [(0.0, 0.0), (200.0, 0.0), (300.0, 120.0), (520.0, 130.0), (600.0, 40.0)]
RADII = [25.0, 12.0, 40.0]
LINE, CURVES = _route(NODES, RADII)
MOVED_LINE, MOVED_CURVES = _route([*NODES[:-1], (640.0, 90.0)], RADII)
def _types() -> list[dict]:
return [
{
"type_id": t.type_id,
"group": t.group,
"placement": t.placement,
"managed_by": t.managed_by,
}
for t in structure_type_map().values()
]
def _server(line, curves, snap):
extras = tuple(
(float(s["chainage_m"]), str(s["title"]))
for s in plan_stations(
parse_pipe_points(PIPES), [SimpleNamespace(options=None, **item) for item in STRUCTURES]
)
)
data = core.generate_sections(
line,
_Flat(),
core.SectionGenerationOptions(extra_stations=extras),
design_curves=curves,
station_snap=snap,
)
return data["longitudinal"]["stations"]
def _first_changed_m() -> float:
"""옮긴 꼭짓점의 앞 접선점(BC) 누가거리 — 노선 = 처음 바뀐 접선점부터 끝."""
_gap, at = core._project_chainage(
*core._clean_polyline(np.asarray(LINE)), tuple(CURVES[-1]["start"])
)
return at
CASES = [
{"name": "곡선표", "line": LINE, "curves": CURVES, "snap": False},
{"name": "스냅", "line": LINE, "curves": CURVES, "snap": True},
{"name": "곡선표없음", "line": LINE, "curves": [], "snap": False},
]
@pytest.fixture(scope="module")
def r() -> dict:
job = {
"cases": CASES,
"pipes": PIPES,
"structures": STRUCTURES,
"types": _types(),
"move": {
"line": LINE,
"curves": CURVES,
"movedLine": MOVED_LINE,
"movedCurves": MOVED_CURVES,
"fromM": _first_changed_m(),
},
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_344_3_pipeline_stations.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_상수_같음(r):
c = r["constants"]
assert c["interval"] == cfg.SECTION_STATION_INTERVAL_M
assert c["endpoint"] == cfg.SECTION_INCLUDE_ENDPOINT
assert c["taper"] == cfg.CURVE_WIDENING_TAPER_M
assert [tuple(row) for row in c["table"]] == list(cfg.CURVE_WIDENING_TABLE_M)
assert c["arm"] == core.PLAN_RADIUS_ARM_M and c["straight"] == core.PLAN_RADIUS_STRAIGHT_M
def _close(a, b) -> bool:
if a is None or b is None:
return a is b
return abs(float(a) - float(b)) <= _TOL
_KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe", "structure"}}
@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES])
def test_서버_측점_목록과_같음(r, index):
case = CASES[index]
server = _server(case["line"], case["curves"], case["snap"])
ours = r["cases"][index]
assert [s["chainage_m"] for s in server] == pytest.approx(
[s["chainageM"] for s in ours], abs=_TOL
)
problems = []
for py, ts in zip(server, ours, strict=True):
where = f"{py['chainage_m']}"
if ts["kind"] not in _KIND[py["kind"]]:
problems.append(f"{where} 종류 {py['kind']} vs {ts['kind']}")
pairs = {
"x": (py["center_x"], ts["x"]),
"y": (py["center_y"], ts["y"]),
"방위각": (py["azimuth_deg"], ts["azimuthDeg"]),
"반경": (py["plan_radius_m"], ts["planRadiusM"]),
"확폭": (py["curve_widening_m"], ts["curveWideningM"]),
"좌측x": (py["frame"]["left_xy"][0], ts["left"][0]),
"좌측y": (py["frame"]["left_xy"][1], ts["left"][1]),
}
for label, (a, b) in pairs.items():
if not _close(a, b):
problems.append(f"{where} {label} {a} vs {b}")
if py["curve_outer_side"] != ts["curveOuterSide"]:
problems.append(f"{where} 바깥쪽 {py['curve_outer_side']} vs {ts['curveOuterSide']}")
assert not problems, problems[:10]
# 확폭 · 반경이 실제로 섬(시험이 빈 값만 대조하지 않게)
assert any(s["curveWideningM"] > 0 for s in ours) and any(s["planRadiusM"] for s in ours)
def test_열쇠(r):
ours = r["cases"][0]
keys = {s["key"]: s for s in ours}
assert ours[0]["key"] == "end:BP" and ours[-1]["key"] == "end:EP"
assert keys["pipe:p57"]["chainageM"] == 57.0 and keys["pipe:p57"]["id"] == "p57"
assert keys["pipe:p140"]["kind"] == "pipe" and "grid:7" not in keys # 격자 위 관 = 관 열쇠 하나
assert {"pipe:p-rev:start", "pipe:p-rev", "pipe:p-rev:end"} <= keys.keys()
assert {"structure:s-wall:start", "structure:s-wall", "structure:s-wall:end"} <= keys.keys()
for s in ours:
if s["kind"] == "grid":
assert s["key"] == f"grid:{round(s['chainageM'] / 20)}"
assert len(keys) == len(ours) # 열쇠 겹침 없음
assert r["text"] == ["0.00", "12.35", "40.00", "700.00"]
def test_스냅_측점은_원래_격자_번호(r):
plain = {s["key"]: s["chainageM"] for s in r["cases"][0] if s["kind"] == "grid"}
snapped = {s["key"]: s["chainageM"] for s in r["cases"][1] if s["kind"] == "grid"}
assert plain.keys() == snapped.keys() # 옮겨도 번호 그대로 · 겹침 없음
moved = {k: (plain[k], snapped[k]) for k in plain if plain[k] != snapped[k]}
assert moved and all(abs(a - b) <= 10 for a, b in moved.values())
def test_노선_한_점_옮김_앞_구간_열쇠_그대로(r):
m = r["move"]
from_m = _first_changed_m()
before = {k: c for k, c in m["before"]}
after = dict(m["after"])
ahead = [k for k, c in m["before"] if c < from_m]
assert ahead and all(after.get(k) == before[k] for k in ahead)
# 범위(옮긴 접선점 − 여유 20 m) 밖은 앞 객체 그대로 · 안은 새로 셈
for (key, chainage), same in zip(m["after"], m["same"], strict=True):
if chainage < from_m - 20 and key in before:
assert same, key
assert not all(m["same"])
assert m["empty"] == []