"""PLAN 511-B B-4 (요구 320-4) — [저장] 횡단 한 바퀴 파이썬 길 = Node 길(`cross.enforce`) · 형상 얹기 Node. 못박는 것 - 가짜 지형(DTM · 빈칸 = 지표면 끝) · 관 지점(세월교 · 물넘이포장 둘 · 배관) · 구조물(포장 · 맹암거 · 소단 · 사토장)로 파이썬 길(`_enforce_on_python` = 설계선 → 표본기 넓힘 → 포장 → 하강)과 Node 길 (`_enforce_on_node` · TS `B06_Section_Cross_Save.enforceSavedCross`)의 측점 설계 · 넓힌 표본 0 차. - 그 사이 넓힘 · 지표면 끝 · 포장 켬 · 끔 · 기울임 · 세월교 하강 · 소단 · 사용자 값 승계가 실제로 일어남. - 구조물 정본을 못 읽으면 두 길 다 소단 = 저장분 그대로 · 포장 · 맹암거 구간 없음. - DTM 아닌 지표면(tin) = 격자 없음(파이썬 길) · 형상 얹기 = Node(`facility.attach`) 부름. ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_save_mirror.py -q """ from __future__ import annotations import copy import json import math import sys from pathlib import Path import numpy as np import pytest HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] for path in (ROOT, HERE): if str(path) not in sys.path: sys.path.insert(0, str(path)) from test_349_0n_cross_enforce_mirror import PIPES # noqa: E402 import B05_Profile.B05_Profile_Engine_Sections as sections_mod # noqa: E402 import B05_Profile.B05_Profile_Structures_Repository as structures_repo # noqa: E402 import B06_Section.B06_Section_Engine_Tilt as tilt_mod # noqa: E402 import B06_Section.B06_Section_Router_Design_Ranges as ranges_mod # noqa: E402 from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # 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_surface_sampler import DtmGridSampler # noqa: E402 from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" ) SURFACE = {"method": "dtm", "source_filter": "classification", "smooth": False} STRUCTURES = [ StructureInstance( structure_id="pv1", type_id="pavement_concrete", placement="interval", start_m=40, end_m=80 ), StructureInstance( structure_id="ud1", type_id="cross_underdrain", placement="point", chainage_m=160, options={"culvert_slope_pct": 2, "culvert_height_m": 0.5}, ), StructureInstance( structure_id="bm1", type_id="berm", placement="interval", start_m=180, end_m=230, options={"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0}, ), StructureInstance( structure_id="sp1", type_id="spoil_bank", placement="site", chainage_m=335, options={"capacity_m3": 300, "side": "자동(성토 쪽)"}, ), ] OFFSETS = [round(-20 + 0.5 * i, 1) for i in range(81)] def _plan(chainage: float) -> float: return round(100.5 + 0.6 * math.sin(chainage / 53) + 0.002 * chainage, 4) def _npz(root: Path) -> None: """DTM 격자 — y = 노선 왼쪽(+) · −42 m 아래 빈칸(지표면 끝) · x 180~240 은 −30 m 아래도.""" models = root / "B04_PreProcess" / "models" models.mkdir(parents=True) x = np.arange(-10.0, 431.0, 1.0) y = np.arange(-80.0, 81.0, 1.0) xx, yy = np.meshgrid(x, y) # 가로 기울기 0.68 ~ 0.78(반폭 20 m 에서 안 닫히는 측점 · 닫히는 거리 갈림 · 지표면 끝) z = 100 + (0.73 + 0.05 * np.sin(xx / 40)) * yy + 0.8 * np.sin(xx / 37) + 0.3 * np.cos(yy / 3) valid = ~((yy < -42) | ((xx >= 180) & (xx <= 240) & (yy < -30))) np.savez(models / f"dtm_{SURFACE['source_filter']}.npz", x=x, y=y, z=z, valid_mask=valid) def _sections(root: Path) -> tuple[dict, list[dict]]: sampler = DtmGridSampler.from_npz( root / "B04_PreProcess" / "models" / f"dtm_{SURFACE['source_filter']}.npz" ) chainages = sorted({20.0 * i for i in range(21)} | {p["chainage_m"] for p in PIPES}) longitudinal = { "design_profiles": [ {"samples": [{"chainage_m": c, "elevation_m": _plan(c)} for c in chainages]} ] } out = [] for index, c in enumerate(chainages): xy = np.array([[c, o] for o in OFFSETS]) z, _valid = sampler.sample_xy(xy) samples = [ { "offset_m": o, "x": c, "y": o, "z": round(float(e), 6), "elevation_m": round(float(e), 6), "valid": True, } for o, e in zip(OFFSETS, z) ] # 저장분 = 옛 입력(포장 · 기울임 · 하강은 지금 시설과 다르게) + 사용자 값 options = dict( ground_type="soil" if index % 3 == 0 else "ripping_rock", section_mode="left_cut" if index % 2 else "right_cut", ditch_type="standard", paved=index % 4 == 0, standard=STANDARD_CROSS_SECTION, surface_drop_m=0.0, shoulder_extend_left_m=0.3 if index % 5 == 0 else 0.0, ) design = compute_cross_design(samples, _plan(c), **options) design.update(status="provisional", pavement_suggested=False, structure_note="메모") if index % 5 == 0: design["shoulder_extend_left_m"] = 0.3 if index % 7 == 0: design["cut_slope_ratio_user"] = 0.8 if index % 6 == 0: design["berm"] = {"width_m": 1.5, "interval_m": 5.0, "slope_deg": 0.0, "depth_m": 2.0} design["berm_depth_left_m"] = 0.5 out.append( { "chainage_m": c, "center_x": c, "center_y": 0.0, "frame": {"origin": {"x": c, "y": 0.0}, "left_xy": [0.0, 1.0]}, "plan_radius_m": 60.0 if index == 4 else None, "curve_outer_side": "left" if index == 4 else None, "curve_widening_m": 0.5 if index == 4 else None, "samples": samples, "design": design, } ) return longitudinal, out def _facilities(monkeypatch, readable: bool = True) -> None: pipes = parse_pipe_points(PIPES) def structures(_root): if not readable: raise OSError("구조물 정본 못 읽음(시험)") return 0, STRUCTURES monkeypatch.setattr(sections_mod, "route_pipe_points", lambda *_a, **_k: pipes) for module in (structures_repo, ranges_mod, tilt_mod): monkeypatch.setattr(module, "load_structures", structures) def _both(tmp_path: Path, monkeypatch, readable: bool = True): root = tmp_path / "p" _npz(root) _facilities(monkeypatch, readable) longitudinal, sections = _sections(root) py, node = copy.deepcopy(sections), copy.deepcopy(sections) prebuild._enforce_on_python(longitudinal, py, root, None, SURFACE) grid = prebuild.design_grid_file(root, SURFACE) assert grid is not None and grid.is_file() assert prebuild._enforce_on_node(longitudinal, node, root, None, grid) return sections, py, node def _same(py: list[dict], node: list[dict]) -> None: """값 0 차(수는 값으로 — Node 를 거친 저장분 칸은 5.0 이 5 로 옴).""" for left, right in zip(py, node, strict=True): assert left["samples"] == right["samples"], left["chainage_m"] assert left["design"] == right["design"], left["chainage_m"] def test_저장_횡단_한_바퀴_파이썬_길_Node_길_0차(tmp_path, monkeypatch): before, py, node = _both(tmp_path, monkeypatch) _same(py, node) by = {s["chainage_m"]: s for s in node} widened = [s["chainage_m"] for s in node if len(s["samples"]) > len(OFFSETS)] assert len(widened) >= 5, widened # 넓힘이 실제로 돎 assert any(s["design"].get("slope_unclosed") for s in node) # 지표면 끝까지 넓혀도 안 닫힘 assert ( by[60.0]["design"]["paved"] is True and by[100.0]["design"]["paved"] is False ) # 포장 켬 · 끔 assert by[160.0]["design"]["tilted"] is True # 맹암거 자리 기울임 assert by[250.0]["design"]["paved"] is True and by[250.0]["design"]["tilted"] is True # 물넘이 assert by[110.0]["design"]["surface_drop_m"] > 0 # 세월교 하강 assert by[200.0]["design"]["berm"]["width_m"] == 1.0 # 소단 = 구조물 정본 구간 assert ( "berm" not in by[120.0]["design"] or by[120.0]["design"]["berm"] is None ) # 구간 밖 = 없음 assert all(s["design"]["structure_note"] == "메모" for s in node) # 사용자 값 승계 assert by[0.0]["design"]["shoulder_extend_left_m"] == 0.3 assert any(a["design"] != b["design"] for a, b in zip(before, node)) def test_구조물_정본_못_읽음_소단_저장분_그대로(tmp_path, monkeypatch): before, py, node = _both(tmp_path, monkeypatch, readable=False) _same(py, node) by = {s["chainage_m"]: s for s in node} saved = {s["chainage_m"]: s for s in before} assert by[0.0]["design"]["berm"] == saved[0.0]["design"]["berm"] # 저장분 소단 assert by[60.0]["design"]["paved"] is False # 포장 구간 없음(물넘이 밖) assert not by[160.0]["design"].get("tilted") # 맹암거 구간 없음(칸 없음) def test_DTM_아닌_지표면은_격자_없음(tmp_path): root = tmp_path / "p" _npz(root) assert prebuild.design_grid_file(root, {**SURFACE, "method": "tin"}) is None assert prebuild.design_grid_file(root, {**SURFACE, "source_filter": "없음"}) is None def test_형상_얹기는_Node(monkeypatch): from B06_Section import B06_Section_Engine_Attach as attach_mod from common_util import common_util_calc_runner as runner calls = [] real = runner.run_calc monkeypatch.setattr( runner, "run_calc", lambda ids, inputs: calls.append(ids) or real(ids, inputs) ) sections = [ {"chainage_m": float(c), "center_x": float(c), "center_y": 0.0} for c in range(0, 401, 10) ] misses = attach_mod.attach_shapes(sections, parse_pipe_points(PIPES), STRUCTURES) assert calls == [["facility.attach"]] assert misses == {"outside": [], "far": []} keys = {k for s in sections for k in s} - {"chainage_m", "center_x", "center_y"} assert {"ford", "ford_pavement", "culvert", "pavement", "underdrain"} <= keys # 파이썬 짝(Node 못 부를 때)과 같음 py = [ {"chainage_m": s["chainage_m"], "center_x": s["center_x"], "center_y": 0.0} for s in sections ] attach_mod._attach_python(py, parse_pipe_points(PIPES), STRUCTURES) assert json.loads(json.dumps(py)) == json.loads(json.dumps(sections))