diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 1594a78c7..53f7ec7cd 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -46,6 +46,7 @@ export type SectionSpecs = Partial< export interface CrossInput { standard: unknown; byKey: Record>; + rockBoundaryDefaultM?: number | null; } const mean = (values: number[]): number => values.reduce((sum, v) => sum + v, 0) / values.length; @@ -76,7 +77,7 @@ export function crossWithUphill( sectionMode: `${uphillSide(station) ?? "left"}_cut`, ...input.byKey[station.key], }; - return { standard: input.standard, byKey }; + return { ...input, byKey }; } const kindOf = (station: PipelineStation): SectionStation["kind"] => diff --git a/B06_Section/logic/B06_Section_Cross_Stage.ts b/B06_Section/logic/B06_Section_Cross_Stage.ts index 62ab354c2..4cb2b8dfd 100644 --- a/B06_Section/logic/B06_Section_Cross_Stage.ts +++ b/B06_Section/logic/B06_Section_Cross_Stage.ts @@ -47,6 +47,8 @@ export type CrossStationInput = Partial< export interface CrossStageInput { standard: StandardCrossSectionSpec; byKey: Record; + /** 암 경계 기본 오프셋(m · 서버 config) — 입력이 없는 측점(초안 새 측점)도 서버처럼 이 값(349-0p). */ + rockBoundaryDefaultM?: number | null; } /** 측점 하나의 엔진 입력. */ @@ -113,6 +115,8 @@ export function createCrossStage(deps: CrossStageDeps = {}) { Array.isArray(pipes) && Array.isArray(structures) ? facilityRanges(pipes, structures) : null; const optionsOf = (station: PipelineStation): CrossDesignOptions => ({ ...optionsFor(station, input.byKey[station.key], input.standard), + rockBoundaryOffsetM: + input.byKey[station.key]?.rockBoundaryOffsetM ?? input.rockBoundaryDefaultM ?? null, ...(ranges ? facilityAt(ranges, station.chainageM) : {}), }); for (const station of ground.stations) { diff --git a/resources/tester/helper_349_0n_cross_enforce.cjs b/resources/tester/helper_349_0n_cross_enforce.cjs index 03fabf7df..18d7d9ede 100644 --- a/resources/tester/helper_349_0n_cross_enforce.cjs +++ b/resources/tester/helper_349_0n_cross_enforce.cjs @@ -49,7 +49,11 @@ function runStage(facilities) { generation: 1, range: null, inputs: { - cross: { standard: job.standard, byKey: job.byKey }, + cross: { + standard: job.standard, + byKey: job.byKey, + rockBoundaryDefaultM: job.rockBoundaryDefaultM ?? null, + }, pipes: facilities ? job.pipes : null, structures: facilities ? job.structures : null, }, diff --git a/resources/tester/test_349_0n_cross_enforce_mirror.py b/resources/tester/test_349_0n_cross_enforce_mirror.py index 633584ece..ddf794d18 100644 --- a/resources/tester/test_349_0n_cross_enforce_mirror.py +++ b/resources/tester/test_349_0n_cross_enforce_mirror.py @@ -93,8 +93,8 @@ def _elevation(chainage: float) -> float: return round(100 + 0.6 * math.sin(chainage / 53) + 0.002 * chainage, 4) -def _stations() -> list[dict]: - pipe_at = {p["chainage_m"]: f"pipe:p{i}" for i, p in enumerate(PIPES)} +def _stations(pipes: list[dict] = PIPES) -> list[dict]: + pipe_at = {p["chainage_m"]: f"pipe:p{i}" for i, p in enumerate(pipes)} chainages = sorted({20.0 * i for i in range(21)} | set(pipe_at)) out = [] for c in chainages: diff --git a/resources/tester/test_349_0p_fractional_station.py b/resources/tester/test_349_0p_fractional_station.py new file mode 100644 index 000000000..156bee2c9 --- /dev/null +++ b/resources/tester/test_349_0p_fractional_station.py @@ -0,0 +1,176 @@ +"""PLAN 349-0p — 관 · 구조물 측점이 소수 자리(386.6 · 150.3)인 노선 꼴 · 입력 없는 측점 암 경계. + +· 세월교 · 물넘이 · 관이 소수 자리 누가거리에 선 노선 — cross 단계 결과(포장 · 노면 하강 · 기울임 · + 면적 · 흙 · 암 나눔) = 서버 [확정] 보정 뒤 설계 +· 저장 입력이 없는 측점(초안 새 측점) — 암 경계 = 기본 오프셋(서버 config · 화면이 서버 문맥에서 + 기억한 값) · 기본값이 안 오면 옛 그대로(경계 없음 = 깎기 전부 암) +짝: `helper_349_0n_cross_enforce.cjs` · 틀 = `test_349_0n_cross_enforce_mirror.py`. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +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 OFFSETS, _stations, _z # 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 as design_mod # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import culvert_sets_from # 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 config.config_system_design import ( # noqa: E402 + STANDARD_CROSS_SECTION, + STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +PIPES = [ + {"chainage_m": 150.3, "options": {"pipe_diameter_mm": 800}}, + { + "chainage_m": 320.4, + "facility": "ford_pavement", + "options": {"ford_width_m": 12, "ford_slope_deg": 4}, + }, + {"chainage_m": 386.6, "facility": "ford_bridge", "options": {"ford_height_m": 0.25}}, +] +STRUCTURES = [ + StructureInstance( + structure_id="pv1", + type_id="gravel_surfacing", + placement="interval", + start_m=40.3, + end_m=80.6, + ), +] +OWN_ROCK = -1.0 # 저장 입력이 있는 측점(짝수 격자)만 — 나머지는 초안 새 측점처럼 입력 없음 + + +def _inputs(stations: list[dict]) -> dict: + return { + s["key"]: {"sectionMode": "left_cut", "rockBoundaryOffsetM": OWN_ROCK} + for s in stations + if s["key"].startswith("grid:") and int(s["key"][5:]) % 2 == 0 + } + + +def _run(tmp: Path, name: str, default: float | None) -> dict: + stations = _stations(PIPES) + job = { + "pipes": PIPES, + "structures": [s.model_dump(mode="json") for s in STRUCTURES], + "stations": stations, + "probe": [0], + "standard": STANDARD_CROSS_SECTION, + "byKey": _inputs(stations), + "rockBoundaryDefaultM": default, + } + path = tmp / f"{name}.json" + path.write_text(json.dumps(job), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_349_0n_cross_enforce.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["stage"]["byKey"] + + +@pytest.fixture(scope="module") +def r(tmp_path_factory) -> dict: + tmp = tmp_path_factory.mktemp("frac") + return { + "stations": _stations(PIPES), + "with": _run(tmp, "with", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), + "without": _run(tmp, "without", None), + } + + +@pytest.fixture(scope="module") +def server(r) -> list[dict]: + """서버 [확정] — 저장분(입력 없는 측점은 config 암 경계) → 포장 구간 → 세월교 하강.""" + patch = pytest.MonkeyPatch() + sets = culvert_sets_from(parse_pipe_points(PIPES)) + for module in (design_mod, tilt_mod): + patch.setattr(module, "load_culvert_sets", lambda _root: sets) + patch.setattr(module, "load_structures", lambda _root: (0, STRUCTURES)) + patch.setattr(structures_repo, "load_structures", lambda _root: (0, STRUCTURES)) + own = _inputs(r["stations"]) + longitudinal = { + "design_profiles": [ + { + "samples": [ + {"chainage_m": s["chainageM"], "elevation_m": s["elevation"]} + for s in r["stations"] + ] + } + ] + } + sections = [] + for s in r["stations"]: + samples = [ + {"offset_m": o, "elevation_m": _z(o, s["chainageM"]), "valid": True} for o in OFFSETS + ] + rock = OWN_ROCK if s["key"] in own else STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M + design = compute_cross_design( + samples, + s["elevation"], + ground_type="ripping_rock", + section_mode="left_cut", + ditch_type="standard", + standard=STANDARD_CROSS_SECTION, + rock_boundary_offset_m=rock, + ) + sections.append({"chainage_m": s["chainageM"], "samples": samples, "design": design}) + try: + root = Path("unused") + design_mod.enforce_pavement_ranges(longitudinal, sections, root, STANDARD_CROSS_SECTION) + design_mod.enforce_ford_surface_drops(longitudinal, sections, root, STANDARD_CROSS_SECTION) + finally: + patch.undo() + return sections + + +def test_소수_자리_측점_결과_서버와_같음(r, server): + keys = ("cut_area_m2", "cut_soil_area_m2", "cut_rock_area_m2", "fill_area_m2") + for station, section in zip(r["stations"], server, strict=True): + a, b = r["with"][station["key"]], section["design"] + for key in keys: + assert a[key] == pytest.approx(b[key], abs=2e-3), (station["key"], key) + for key in ("paved", "tilted", "surface_drop_m", "rock_boundary_offset_m"): + assert a.get(key) == b.get(key), (station["key"], key) + by = {s["key"]: s["chainageM"] for s in r["stations"]} + assert r["with"]["pipe:p2"]["surface_drop_m"] == 0.25 and by["pipe:p2"] == 386.6 + assert r["with"]["pipe:p1"]["tilted"] and r["with"]["grid:16"]["paved"] # 320.4 ± 6 + assert r["with"]["grid:4"]["paved"] and not r["with"]["grid:2"]["paved"] # 40.3 ~ 80.6 + + +def test_입력_없는_측점_암_경계_기본값(r): + rock = [ + s["key"] + for s in r["stations"] + if r["with"][s["key"]]["cut_area_m2"] > 0.01 and s["key"] not in _inputs(r["stations"]) + ] + assert rock # 깎기 있는 새 측점이 실제로 있음 + for key in rock: + assert r["with"][key]["rock_boundary_offset_m"] == STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M + assert r["without"][key]["cut_soil_area_m2"] == 0 # 기본값이 안 오면 옛 그대로(전부 암) + assert any(r["with"][key]["cut_rock_area_m2"] > 0 for key in rock)