Files
Aislo/resources/tester/test_346_2_pipeline_cross_page.py
T
eomsangdonandClaude Opus 5.5 72bab97063 fix(계산묶음): 349-0l 되돌리면 종단도도 저장 기준 그대로 — 전 측점 다시 셈 예약 없앰 · 되돌림 길 기록
- 종단도 adoptPipeline(초안 버림) = 열 때처럼 그리기만 — 편집 때 쓰던 전 측점 다시 셈 예약(rebuild → crossPreview) 안 함 · 낡음은 그리기가 잼
- 계산 묶음 — 상세를 초안 전 상세로 되돌린 뒤에 종단도를 저장 기준으로(그 전엔 초안 상세를 낡음으로 봄)
- 묶음 기록 restored · restoreWhy(saved · unchanged · stations · no-saved)
- cross 단계에 지형 격자(미교차 넓힘 · 349-0j 짝) · test_346_2 서버 대조도 넓힘 포함
- 시험 test_349_0l_undo_draw 4건 — 진짜 종단도 낡음 판정 · crossPreview 를 거침(옛 코드 되돌린 뒤 7822 대 열기 3911 재현)

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

297 lines
12 KiB
Python

"""PLAN 346-1 · 346-2 붙임 — 페이지 계산 묶음에 cross · masshaul · haul · drainage · corridor.
· 열 때 한 번(저장 노선 · 발자국 · 저장 횡단 입력이 측점 열쇠로 모임)
초안 없으면 3D 낡음 · 흙 배분 안 함
· 노선 점 옮김 한 번에 cross · footprint · masshaul 까지 · 단계 합 0.3 s 안
· 누가토량 = 서버 엔진(generate_sections · compute_cross_design · 미교차 넓힘 extend_unclosed)으로
같은 노선을 셈한 값과 같은 자리수
· 상단측(단면 유형 기본값) = 서버 `uphill_side` 짝(거울)
· 늦은 응답 버림(흙 배분 끊김 · 옛 차례 haul 안 들어옴)
· 저장 관 목록이 좌표로 새 노선을 따라감
· 노선 초안 동안 계획고 = alignment-draft 노선 0 칸 · 초안을 버리면 비움
짝: `helper_346_2_pipeline_cross_page.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜).
"""
from __future__ import annotations
import csv
import json
import math
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
import pytest
from shapely.geometry import LineString, Point
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_Engine_Grade import resolve_grade_options # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
)
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed # noqa: E402
from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
from common_util.common_util_structure_station import plan_stations # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
PROJECT = ROOT / "storage/1/3/6d3b2812-6d5a-4168-a932-40e4ae303d5a" # 검증_sub7
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_nodes.csv"
MIN_RADIUS_M = 12.0
INTERVAL_M = 20.0
BUDGET_MS = 300.0
ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")}
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 _line(nodes: list[list[float]]) -> list[list[float]]:
built = build_planned_polyline(
[tuple(n) for n in nodes],
min_radius_m=MIN_RADIUS_M,
simplify=False,
curve_flags=[True] * len(nodes),
radii=[None] * len(nodes),
)
return [list(v) for v in built.vertices]
def _fake(folder: Path) -> tuple[Path, list[list[float]]]:
"""가짜 격자 — 칸 폭 고르지 않음 · 기운 땅(상단측이 갈림)."""
rng = np.random.default_rng(3462)
x = (np.cumsum(rng.uniform(0.95, 1.1, 300)) + 1000.0).astype(np.float32)
y = (np.cumsum(rng.uniform(0.95, 1.1, 200)) + 2000.0).astype(np.float32)
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
z = 500 + 0.12 * (xx - 1000) - 0.35 * (yy - 2000) + 2.5 * np.sin(xx / 11.0)
npz = folder / "dtm_fake.npz"
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=np.ones(z.shape, dtype=bool))
return npz, [[1020.0, 2030.0], [1100.0, 2050.0], [1170.0, 2120.0], [1270.0, 2150.0]]
def _real(_folder: Path) -> tuple[Path, list[list[float]]]:
if not (REAL_GRID.is_file() and REAL_NODES.is_file()):
pytest.skip("검증_sub7 없음")
with REAL_NODES.open(encoding="utf-8") as handle:
return REAL_GRID, [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)]
def _pipes(line: list[list[float]]) -> list[dict]:
shape = LineString(line)
out = []
for ratio, pid in ((0.3, "p-a"), (0.62, "p-b")):
at = round(shape.length * ratio, 3)
point = shape.interpolate(at)
out.append({"id": pid, "chainage_m": at, "x": point.x, "y": point.y, "source": "user"})
return out
def _sections(line, sampler, pipes, curves=None) -> dict:
extras = tuple(
(float(s["chainage_m"]), str(s["title"]))
for s in plan_stations(parse_pipe_points(pipes), [])
)
return core.generate_sections(
np.asarray(line, dtype=np.float64),
sampler,
core.SectionGenerationOptions(station_interval_m=INTERVAL_M, extra_stations=extras),
design_curves=curves or None,
)
def _saved_detail(data: dict) -> dict:
"""저장 상세 — 측점마다 넘기는 입력만 담은 설계(지반 유형)."""
sections = [
{**s, "design": {"ground_type": "ripping_rock", "design_elevation_m": s["center_z"]}}
for s in data["cross_sections"]
]
return {"longitudinal": {**data["longitudinal"]}, "cross_sections": sections}
def _run(case) -> dict:
with tempfile.TemporaryDirectory() as folder:
npz, nodes = case(Path(folder))
line = _line(nodes)
sampler = DtmGridSampler.from_npz(npz)
pipes = _pipes(line)
saved = _sections(line, sampler, pipes)
options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None)
policy = design_ground_following_profile(saved["longitudinal"], options)[0]["policy"]
job = {
"nodes": nodes,
"line": line,
"minRadius": MIN_RADIUS_M,
"policy": policy,
"types": _types(),
"interval": INTERVAL_M,
"pipes": pipes,
"standard": STANDARD_CROSS_SECTION,
"conversion": ONES,
"detail": _saved_detail(saved),
"haulDelayMs": 40,
}
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
grid = Path(folder) / "grid.bin"
grid.write_bytes(dtm_grid_bytes(npz))
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_346_2_pipeline_cross_page.cjs"), str(path), str(grid)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=180,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
# 서버 엔진으로 같은 노선(옮긴 뒤) — 측점 · 지반 · 횡단 면적 · 상단측
moved = out["moved"]
ts_pipes = [
{"id": s["key"].split(":", 1)[1], "chainage_m": s["chainage"], "source": "user"}
for s in moved["stations"]
if s["kind"] == "pipe"
]
server = _sections(moved["line"], sampler, ts_pipes, moved["curves"])
out["server"] = [
{
"chainage": s["chainage_m"],
"uphill": s.get("uphill_side"),
"samples": s["samples"],
"planRadius": s.get("plan_radius_m"),
"outer": s.get("curve_outer_side"),
"widening": s.get("curve_widening_m"),
"frame": s["frame"],
}
for s in server["cross_sections"]
]
out["pipes"] = pipes
out["sampler"] = sampler
return out
@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub7"])
def r(request) -> dict:
return _run(request.param)
def test_열_때_한_번_발자국_저장_횡단_입력(r):
o = r["open"]
assert o["outcome"] == "done"
assert {"cross", "footprint", "masshaul"} <= set(o["stages"])
assert o["crossCount"] == o["stations"] and o["footprint"] > 2
assert o["carried"] == o["stations"] # 저장 측점마다 입력이 측점 열쇠로
assert o["corridor"] == 0 and o["haulCalls"] == 0 and o["detail"] is None
assert sorted(k for k, _ in o["pipeKeys"]) == ["pipe:p-a", "pipe:p-b"]
def test_노선_옮김_한_번에_유토곡선까지_시간(r):
m = r["moved"]
run = m["run"]
assert run["outcome"] == "done" and run["sources"] == ["route"]
for stage in ("route", "stations", "ground", "planline", "profile", "cross", "footprint"):
assert stage in run["stages"], stage
assert "masshaul" in run["stages"]
assert (
sum(run["stages"][s] for s in run["stages"] if s not in ("haul", "drainage", "corridor"))
< BUDGET_MS
)
assert run["totalMs"] < BUDGET_MS
d = m["detail"]
assert d["sections"] == len(m["stations"]) == d["designs"] == d["stationIds"]
assert d["profileMatches"] and d["longitudinal"] > 10
assert m["adoptedLast"]["draft"] is True and m["redraws"] >= 1
assert m["corridor"] >= 1 and True in m["drainage"]
def test_상단측_서버_짝(r):
ts_up = [s["uphill"] for s in r["moved"]["stations"]]
py_up = [s["uphill"] for s in r["server"]]
assert len(ts_up) == len(py_up)
assert ts_up == py_up
def test_누가토량_서버_엔진과_같은_자리수(r):
m = r["moved"]
ts = m["stations"]
py = r["server"]
assert len(ts) == len(py)
nets = []
for a, b in zip(ts, py):
assert abs(a["chainage"] - b["chainage"]) < 1e-3
def compute(samples, a=a, b=b):
return compute_cross_design(
samples,
a["elevation"],
ground_type="ripping_rock",
section_mode=f"{b['uphill'] or 'left'}_cut",
plan_radius_m=b["planRadius"],
curve_outer_side=b["outer"],
curve_widening_m=b["widening"],
)
design = compute(b["samples"])
# 미교차 넓힘 — 묶음 cross 도 같은 지형 격자로 넓힘(349-0j · [저장] 정본 길)
wide = extend_unclosed(b["samples"], b["frame"], compute, r["sampler"])
if wide is not None and (wide[1]["left_m"] or wide[1]["right_m"]):
design = compute(wide[0])
assert abs(design["cut_area_m2"] - a["cut"]) < 0.05 + 0.01 * design["cut_area_m2"]
assert abs(design["fill_area_m2"] - a["fill"]) < 0.05 + 0.01 * design["fill_area_m2"]
nets.append((b["chainage"], design["cut_area_m2"] - design["fill_area_m2"]))
final = sum((n0 + n1) / 2 * (c1 - c0) for (c0, n0), (c1, n1) in zip(nets, nets[1:]))
got = m["finalCumulative"]
assert got is not None
assert abs(got - final) <= max(1.0, abs(final) * 0.005), (got, final)
def test_늦은_응답_버림(r):
late = r["late"]
assert late["genB"] > late["genA"]
assert late["haul"]["aborted"] >= 1 # 앞 차례 흙 배분 요청 끊김
assert f"haul@{late['genA']}" not in late["haulCommits"] # 옛 차례 배분 안 들어옴
assert late["haulCommits"][-1] == f"haul@{late['genB']}"
def test_저장_관_목록이_좌표로_따라감(r):
m = r["moved"]
line = LineString(m["line"])
got = {s["key"]: s["chainage"] for s in m["stations"] if s["kind"] == "pipe"}
for pipe in r["pipes"]:
want = math.floor(line.project(Point(pipe["x"], pipe["y"])) + 0.5) # 구조물 측점 = 1 m
assert abs(got[f"pipe:{pipe['id']}"] - want) < 1e-6, (pipe["id"], got, want)
assert any(abs(got[f"pipe:{p['id']}"] - p["chainage_m"]) > 0.5 for p in r["pipes"]) # 정말 밀림
def test_초안_동안_계획고_노선_0_칸(r):
assert r["moved"]["alignmentDraft"] is not None # 노선 초안 = 노선 0 칸에 남음
g = r["gradeDraft"]
assert g["pending"] and list(g["pending"]["station_offsets"].values()) == [0.4]
assert g["sources"] == ["grade"] and "stations" not in g["stages"] and "cross" in g["stages"]
rv = r["reverted"]
assert rv["detail"] is None and rv["alignmentDraft"] is None and rv["pending"] is None
assert rv["adoptedLast"]["draft"] is False