- 종단도 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
297 lines
12 KiB
Python
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
|