Files
Aislo/resources/tester/test_493_3_follow_screen_save.py
T
eomsangdonandClaude Opus 5.5 e434d17928 fix(493-4 관 저장 자리 = 화면 자리): 관 PUT 이 땅 위 좌표를 버려 노선 위 측점 자리로 덮던 것 · GET 관 좌표 = 정본 땅 위 자리
- 원인 = 관 PUT 이 세부유역 계산을 거쳐 속성만 되붙임(carry_facility_attributes · x · y 빠짐) → 저장이 노선 위 측점 자리로 채움 + GET 관 x · y = 노선 위 자리라 [저장] 다시 얹기가 그 자리를 베낌 · 검증_sub2 재현 0.62 m(검증_sub1 1.27 m)
- carry_facility_attributes 가 x · y 도 되붙임 · save_pipe_points = 좌표가 그 누가거리를 낳으면 땅 위 자리 그대로 · 누가거리만 옮긴 낡은 좌표는 노선 위(구조물 fill_structure_coordinates · TS placeOnLine 같은 규칙)
- GET 관 지점 x · y = 정본 좌표(없으면 노선 위 자리) · 지도 lonlat 은 노선 위 그대로
- 거울 시험 test_493_3_follow_screen_save 에 관 x · y(끌기 뒤 화면 = 관 PUT 뒤 정본 · 두 번 고쳐도 그대로 · 누가거리만 옮긴 관 = 노선 위 · 고치기 전 대조 0.2 m 넘게 덮임) · test_428 GET 좌표 글 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:23:18 +09:00

349 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""493-3 화면 ↔ 저장 자리 거울 — 교점 끌기 여러 걸음 뒤 화면 시설 자리 = [저장] 뒤 서버 자리.
브라우저(TS) = 걸음마다 앞 선에서 자리 붙임 → 새 선 따라감
(`helper_493_3_follow_steps.cjs`) · 서버(파이썬) = 저장 노선(L0)의 정본에서 마지막 선(Ln)으로 한 번
(`B05_Profile_Route_Save.followed_pipes` · `B05_Profile_Structures_Router.followed_structures` ·
`B05_Profile_Plus_Stations.followed_plus_rows` 와 같은 셈). 규칙(요구 290) = 관 · 구조물 · 추가
측점 = 땅 위 자리(x · y)에 가장 가까운 새 노선 자리.
493-4 = 관 땅 위 자리(x · y) 자체 — 화면 관 좌표 = 관 PUT(`put_pipe_points` · 속성 되붙임
`carry_facility_attributes` → `save_pipe_points`) 뒤 정본 좌표 · 두 번 고쳐 저장해도 그대로 · 누가거리만
옮긴 관 = 노선 위.
경우 = 직선 · 곡선 R · 교점 더함 · 지움 · 노선 앞 교점 옮김(여러 걸음 · 오가며).
노선 = 서버 `build_planned_polyline`(브라우저 `draftLine` 짝 ·
test_route_polyline_browser_mirror.py) · 4자리(계획노선 csv · `draftLine` `xy4`).
"""
from __future__ import annotations
import copy
import json
import math
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
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_Plus_Stations import followed_plus_rows # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import follow_pipes, vertices_of # noqa: E402
from common_util.common_util_drainage_pipes import ( # noqa: E402
PipePoint,
fill_pipe_coordinates,
)
from common_util.common_util_route_follow import ( # noqa: E402
fill_structure_coordinates,
follow_structures,
)
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
from config.config_system import SECTION_STATION_INTERVAL_M # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
NODES = [
(0.0, 0.0),
(60.0, 8.0),
(110.0, 52.0),
(175.0, 58.0),
(215.0, 15.0),
(290.0, 22.0),
(330.0, 80.0),
]
RADII: list[float | None] = [None, 30.0, 12.0, 25.0, 12.0, 40.0, None]
def _line(nodes, radii=None) -> list[list[float]]:
radii = list(RADII if radii is None else radii)
built = build_planned_polyline(
list(nodes),
min_radius_m=12.0,
simplify=False,
curve_flags=[True] * len(nodes),
radii=radii,
)
return [[round(x, 4), round(y, 4)] for x, y in built.vertices]
def _at(line, chainage, offset=0.0) -> tuple[float, float]:
walked = 0.0
for (ax, ay), (bx, by) in zip(line, line[1:]):
span = math.hypot(bx - ax, by - ay)
if span > 0 and walked + span >= chainage:
t = (chainage - walked) / span
nx, ny = -(by - ay) / span, (bx - ax) / span
return ax + (bx - ax) * t + nx * offset, ay + (by - ay) * t + ny * offset
walked += span
return tuple(line[-1])
def _drag(seat: int, to: tuple[float, float], steps: int, radii=None, nodes=None):
"""교점 `seat` 를 `to` 만큼 여러 걸음으로 옮긴 선들(오가며 — 끝 앞에서 한 번 넘쳤다 돌아옴)."""
nodes = list(nodes or NODES)
x0, y0 = nodes[seat]
path = [k / steps for k in range(1, steps + 1)] + [1.15, 0.85, 1.0]
lines = []
for f in path:
moved = list(nodes)
moved[seat] = (x0 + to[0] * f, y0 + to[1] * f)
lines.append(_line(moved, radii))
return lines
L0 = _line(NODES)
CASES = {
# 노선 앞 교점 옮김 — 뒤 시설 누가거리가 걸음마다 바뀜(483 · 482 보고 「굽은 곳 2~7 m」)
"앞_교점_옮김": [L0, *_drag(1, (-9.0, 13.0), 12)],
"곡선_교점_옮김": [L0, *_drag(2, (7.0, -11.0), 9)],
"R_바꿈": [
L0,
_line(NODES, [None, 30.0, 35.0, 25.0, 12.0, 40.0, None]),
_line(NODES, [None, 30.0, 60.0, 25.0, 12.0, 40.0, None]),
],
"교점_더함": [
L0,
_line([*NODES[:2], (85.0, 20.0), *NODES[2:]], [*RADII[:2], 15.0, *RADII[2:]]),
],
"교점_지움": [L0, _line([*NODES[:2], *NODES[3:]], [*RADII[:2], *RADII[3:]])],
"직선": [L0, _line([NODES[0], (70.0, -4.0), NODES[-1]], [None, 30.0, None])],
}
# 저장 정본(L0 위) — 관 = 세류선 교차(노선 옆 0.2 ~ 0.4 m) · 구간 관 ·
# 구조물 = 점 · 구간 · 사토장 · 추가 측점 = 정수 m
PIPES = [
{"id": "p1", "chainage_m": 75.0, "x": _at(L0, 75.0, 0.3)[0], "y": _at(L0, 75.0, 0.3)[1]},
{"id": "p2", "chainage_m": 141.0, "x": _at(L0, 141.0, -0.25)[0], "y": _at(L0, 141.0, -0.25)[1]},
{
"id": "p3",
"chainage_m": 236.0,
"x": _at(L0, 236.0, 0.4)[0],
"y": _at(L0, 236.0, 0.4)[1],
"start_m": 233.0,
"end_m": 240.0,
},
{"id": "p4", "chainage_m": 330.0, "x": _at(L0, 330.0)[0], "y": _at(L0, 330.0)[1]},
]
STRUCTURES = [
{"structure_id": "s1", "placement": "point", "chainage_m": 128.0},
{
"structure_id": "s2",
"placement": "interval",
"chainage_m": 205.0,
"start_m": 198.0,
"end_m": 214.0,
},
{"structure_id": "s3", "placement": "site", "chainage_m": 280.0},
]
PLUS = [{"chainage_m": c} for c in (93.0, 157.0, 263.0, 301.0)]
# 앞 노선 [저장]이 따라 옮긴 추가 측점 — 좌표 = 옛 땅 자리(노선 옆 · 누가거리 소수 자리) ·
# 누가거리 = 정수 m
PLUS_FOLLOWED = {"chainage_m": 187.0, "at": (187.4, 0.8)}
def _saved_structures() -> list[dict]:
"""서버 정본 꼴 — 저장 때 좌표 채움(`fill_structure_coordinates` · L0)."""
items = [
SimpleNamespace(**{"start_m": None, "end_m": None, "x": None, "y": None, **s})
for s in STRUCTURES
]
fill_structure_coordinates(items, L0)
return [dict(vars(item)) for item in items]
def _saved_plus() -> list[dict]:
"""서버 정본 꼴(`save_plus_stations`) — 누가거리의 L0 위 좌표 4자리 · 따라 옮긴 줄은 옛 땅
자리."""
rows = []
for row in PLUS:
x, y = _at(L0, row["chainage_m"])
rows.append({"chainage_m": row["chainage_m"], "x": round(x, 4), "y": round(y, 4)})
x, y = _at(L0, *PLUS_FOLLOWED["at"])
rows.append({"chainage_m": PLUS_FOLLOWED["chainage_m"], "x": round(x, 4), "y": round(y, 4)})
return sorted(rows, key=lambda row: row["chainage_m"])
def _browser(lines, folder: Path, old_plus: bool = False) -> dict:
source = folder / "in.json"
source.write_text(
json.dumps(
{
"lines": lines,
"pipes": PIPES,
"structures": _saved_structures(),
"plus": _saved_plus(),
"interval": SECTION_STATION_INTERVAL_M,
"oldPlus": old_plus,
}
),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_493_3_follow_steps.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _server(lines) -> dict:
old, new = lines[0], lines[-1]
pipes = [PipePoint(**{k: v for k, v in p.items()}) for p in copy.deepcopy(PIPES)]
fill_pipe_coordinates(pipes, vertices_of(old))
moved = [pipe for _, pipe in follow_pipes(pipes, vertices_of(new))]
items = [SimpleNamespace(**s) for s in _saved_structures()]
fill_structure_coordinates(items, old, only_missing=True)
follow_structures(items, new)
plus = followed_plus_rows(_saved_plus(), new)["rows"]
return {
"pipes": [
{"id": p.id, "chainage_m": p.chainage_m, "start_m": p.start_m, "end_m": p.end_m}
for p in moved
],
"structures": [dict(vars(item)) for item in items],
"plus": [row["chainage_m"] for row in plus],
}
def _xy_gap(line, a, b) -> float:
"""두 누가거리의 새 선 위 자리 차(m) — 판정 = 0.01 m 안."""
(ax, ay), (bx, by) = _at(line, float(a)), _at(line, float(b))
return math.hypot(ax - bx, ay - by)
def _gaps(screen, server, new) -> list:
gaps = []
by_id = {p["id"]: p for p in screen["pipes"]}
for want in server["pipes"]:
got = by_id[want["id"]]
for key in ("chainage_m", "start_m", "end_m"):
if want[key] is not None:
gaps.append((f"관 {want['id']} {key}", got[key], want[key]))
by_sid = {s["structure_id"]: s for s in screen["structures"]}
for want in server["structures"]:
got = by_sid[want["structure_id"]]
for key in ("chainage_m", "start_m", "end_m"):
if want[key] is not None:
gaps.append((f"구조물 {want['structure_id']} {key}", got[key], want[key]))
assert len(screen["plus"]) == len(server["plus"]), (screen["plus"], server["plus"])
for got, want in zip(screen["plus"], server["plus"]):
gaps.append(("추가 측점", got, want))
return [(name, got, want) for name, got, want in gaps if _xy_gap(new, got, want) > 0.01]
@pytest.mark.parametrize("case", list(CASES))
def test_화면_자리_저장_자리_같음(case, tmp_path):
lines = CASES[case]
bad = _gaps(_browser(lines, tmp_path), _server(lines), lines[-1])
assert not bad, bad
def test_고치기_전_추가_측점은_걸음마다_밀림(tmp_path):
"""대조 — 좌표 없이 걸음마다 정수 m 에서 다시 놓던 셈(493-3 전)은 끌기 뒤 서버와 m 단위로
어긋남."""
lines = CASES["앞_교점_옮김"]
bad = _gaps(_browser(lines, tmp_path, old_plus=True), _server(lines), lines[-1])
assert bad and all(name == "추가 측점" for name, _, _ in bad)
assert max(abs(got - want) for _, got, want in bad) >= 2
# ── 493-4 관 땅 위 자리(x · y) — [저장] 관 PUT(세부유역 계산 → 속성 되붙임 → 저장)이 화면 좌표 그대로 ──
def _put_pipes(draft: list[dict], new, tmp_path, monkeypatch, carry_xy: bool = True) -> dict:
"""관 PUT 길(`put_pipe_points`) — 본문 → 계산기가 돌려준 누가거리만의 관(`_rebuilt` 꼴) → 속성
되붙임 → 정본 저장(새 노선) → id → (x, y). `carry_xy=False` = 고치기 전(되붙임이 좌표를 버림)."""
from common_util import common_util_storage as storage
from common_util.common_util_drainage_pipes import (
carry_facility_attributes,
parse_pipe_points,
pipe_points_path,
save_pipe_points,
)
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
(tmp_path / "storage" / "p").mkdir(parents=True)
points = parse_pipe_points(copy.deepcopy(draft))
rebuilt = [PipePoint(chainage_m=round(p.chainage_m, 2), source=p.source) for p in points]
carried = carry_facility_attributes(rebuilt, points)
if not carry_xy:
for point in carried:
point.x = point.y = None
save_pipe_points("storage/p", "sig", carried, vertices_of(new))
saved = json.loads(pipe_points_path("storage/p").read_text(encoding="utf-8"))["points"]
return {p["id"]: (p["x"], p["y"]) for p in saved}
@pytest.mark.parametrize("case", list(CASES))
def test_관_저장_좌표_화면_좌표_같음(case, tmp_path, monkeypatch):
"""끌기 뒤 화면 관 땅 위 자리 = [저장](관 PUT) 뒤 정본 좌표(0.01 m 안)."""
lines = CASES[case]
screen = _browser(lines, tmp_path)["pipes"]
saved = _put_pipes(screen, lines[-1], tmp_path, monkeypatch)
bad = [
(p["id"], round(math.hypot(p["x"] - saved[p["id"]][0], p["y"] - saved[p["id"]][1]), 3))
for p in screen
if math.hypot(p["x"] - saved[p["id"]][0], p["y"] - saved[p["id"]][1]) > 0.01
]
assert not bad, bad
def test_관_두_번_고쳐_저장해도_자리_그대로(tmp_path, monkeypatch):
"""[저장] 정본 좌표에서 다시 끌어 [저장] — 땅 위 자리가 흘러가지 않음(처음 정본 0.01 m 안)."""
first, second = CASES["앞_교점_옮김"], CASES["곡선_교점_옮김"]
(tmp_path / "a").mkdir()
screen = _browser(first, tmp_path / "a")["pipes"]
saved = _put_pipes(screen, first[-1], tmp_path / "a", monkeypatch)
again = [{**p, "x": saved[p["id"]][0], "y": saved[p["id"]][1]} for p in screen]
lines = [first[-1], *second[1:]]
source = tmp_path / "b"
source.mkdir()
(source / "in.json").write_text(
json.dumps({"lines": lines, "pipes": again, "structures": [], "plus": [], "interval": 20}),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_493_3_follow_steps.cjs"), str(source / "in.json")],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
final = _put_pipes(json.loads(proc.stdout)["pipes"], lines[-1], tmp_path / "b", monkeypatch)
for pipe in PIPES:
x, y = final[pipe["id"]]
assert math.hypot(x - pipe["x"], y - pipe["y"]) <= 0.01, pipe["id"]
def test_고치기_전_관_저장은_노선_위로_덮음(tmp_path, monkeypatch):
"""대조 — 되붙임이 좌표를 버리던 셈(493-4 전)은 노선 옆 관(0.2 ~ 0.4 m)을 노선 위 측점 자리로 덮음."""
lines = CASES["앞_교점_옮김"]
screen = _browser(lines, tmp_path)["pipes"]
saved = _put_pipes(screen, lines[-1], tmp_path, monkeypatch, carry_xy=False)
gaps = [math.hypot(p["x"] - saved[p["id"]][0], p["y"] - saved[p["id"]][1]) for p in screen]
assert max(gaps) >= 0.2
def test_누가거리만_옮긴_관은_노선_위로(tmp_path, monkeypatch):
"""배수유역 칸 등에서 누가거리만 옮긴 관(좌표 = 옛 자리) — 저장 = 새 누가거리의 노선 위 자리."""
new = L0
moved = {**PIPES[0], "chainage_m": 110.0}
saved = _put_pipes([moved], new, tmp_path, monkeypatch)
x, y = _at(new, 110.0)
assert math.hypot(saved["p1"][0] - x, saved["p1"][1] - y) <= 0.01