"""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 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="프론트엔드 의존성 미설치", ) 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 _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") def r() -> dict: return _run(_fake) def test_열_때_한_번_발자국_저장_횡단_입력(r): o = r["open"] assert o["outcome"] == "done" assert {"cross", "footprint"} <= 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" not in run["stages"] and "haul" not in run["stages"] # 454 걷음 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"] # 454 — 묶음은 흙 배분을 안 물음(유토곡선 판 선반입만) · 늦은 응답 버림 = 344-4 corridor assert late["haul"]["calls"] == 0 and late["haulCommits"] == [] 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