Files
Aislo/resources/tester/test_346_2_pipeline_cross_page.py
T
eomsangdonandClaude Opus 5.5 a35d49b4e2 feat(계산묶음): 346-1 · 346-2 남은 단계 붙임 — cross · masshaul · haul · drainage · corridor · 노선 초안 동안 유토곡선 = 새 노선 횡단
- design_pipeline_page: 단계 다섯 한 줄씩(부르기만) · 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) · haul · corridor 는 노선 초안 동안만 · 열 때 한 번 셈(발자국 바로 보임)
- 노선 초안 동안 종단도 = 새 노선 상세(pipelineDetail — 지반 · 측점 · 횡단 면적 → 유토곡선) · 저장 노선 횡단 다시 셈 안 함
- 횡단 입력 = 저장 상세의 측점 입력을 측점 열쇠로(design_pipeline_cross · 넘기는 칸 = 서버 CARRY_INPUT_KEYS 와 같음)
- 저장 관 목록(좌표 · id) = 관 입력 바탕 · 첫 계산도 저장 노선 기준으로 좌표 따라감(createFollowStage)
- 노선 초안 동안 계획고 = alignment-draft 노선 0 칸(DRAFT_ROUTE_ID) · 초안 버리면 비움 · [저장] 이음 = sub5
- 상단측 짝(logic/B05_Profile_Pipeline_Detail uphillSide ↔ Engine_Sections_Core uphill_side) · 측점 열쇠 = common_util_station_key 한 벌
- 시험 test_346_2_pipeline_cross_page 14 · 옛 시험 둘 새 꼴에 맞춤(317 · 230_2) · 346-1 도움 파일 창구 셋 가짜

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

286 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)으로
같은 노선을 셈한 값과 같은 자리수
· 상단측(단면 유형 기본값) = 서버 `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 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"),
}
for s in server["cross_sections"]
]
out["pipes"] = pipes
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
design = compute_cross_design(
b["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"],
)
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