From 0be32b8c50ae60eb22a04a59c5ad392de189bafd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:06:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(472-4=20=E2=91=A1=20=EB=82=98?= =?UTF-8?q?=EB=88=94):=20B06=5FSection=5FRouter=5FDesign=20699=EC=A4=84=20?= =?UTF-8?q?=E2=80=94=20=ED=8F=AC=EC=9E=A5=20=C2=B7=20=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=20=EA=B5=AC=EA=B0=84=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20=5FRouter=5FDesign=5FRanges=20?= =?UTF-8?q?=EB=A1=9C(=EB=8F=99=EC=9E=91=20=EA=B0=99=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pavement_suggestions · pavement_ranges · ford_pavement_ranges · ford_surface_drops · ford_drop_at · paved_at 옮김 · Router_Design 이 다시 내보냄(부르는 곳 그대로) - 시험 test_349_0n · test_349_0p — 정본 읽기(load_culvert_sets · load_structures) 갈아 끼움 자리를 나눈 모듈로(까닭: 읽는 함수가 옮겨 감) - Router_Design 699 → 605줄 Co-Authored-By: Claude Opus 5.5 --- B06_Section/B06_Section_Router_Design.py | 116 ++---------------- .../B06_Section_Router_Design_Ranges.py | 113 +++++++++++++++++ .../test_349_0n_cross_enforce_mirror.py | 4 +- .../tester/test_349_0p_fractional_station.py | 4 +- 4 files changed, 130 insertions(+), 107 deletions(-) create mode 100644 B06_Section/B06_Section_Router_Design_Ranges.py diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index c06197696..29231cc3e 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -1,18 +1,13 @@ """B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산. -짝(349-0n): `pavement_ranges` · `ford_surface_drops` · `paved_at` · `ford_drop_at` ↔ -`B06_Section/logic/B06_Section_Cross_Enforce.ts` · 거울 시험 `test_349_0n_cross_enforce_mirror.py`. +포장 · 세월교 구간 읽기 · 판정(짝 349-0n) = `B06_Section_Router_Design_Ranges.py` · 다시 내보냄. """ import json -import logging from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Engine_Sections import find_cross_file -from B05_Profile.B05_Profile_Structures_Repository import load_structures -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets from B06_Section.B06_Section_Engine_Design import ( ROUTE_CARRY_KEYS, compute_cross_design, @@ -21,6 +16,16 @@ from B06_Section.B06_Section_Engine_Design import ( stored_tilt, ) from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges + +# 포장 · 세월교 구간(나눈 파일) — 이 모듈 이름으로 다시 내보냄(부르는 곳 · 시험 갈아 끼움 그대로). +from B06_Section.B06_Section_Router_Design_Ranges import ( # noqa: F401 + ford_drop_at, + ford_pavement_ranges, + ford_surface_drops, + paved_at, + pavement_ranges, + pavement_suggestions, +) from common_util.common_util_cross_berm import ( BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, @@ -29,15 +34,8 @@ from common_util.common_util_cross_berm import ( BermSpec, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal -from common_util.common_util_structure_station import EDGE_TOLERANCE_M from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M -logger = logging.getLogger(__name__) - -# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과 -# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다. -_FORD_DROP_TOLERANCE_M = 0.02 - PREVIEW_DESIGN_FIELDS = ( "ground_type", "roadbed_width_m", @@ -52,98 +50,6 @@ PREVIEW_DESIGN_FIELDS = ( ) -def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: - mapping: dict[float, bool] = {} - stations = longitudinal.get("stations") - for station in stations if isinstance(stations, list) else []: - suggested = station.get("pavement_suggested") - if isinstance(suggested, bool): - mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested - return mapping - - -def pavement_ranges(project_root: Path) -> list[tuple[float, float]]: - """포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수). - - 포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은 - 더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다. - 물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다. - 읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다. - """ - ranges: list[tuple[float, float]] = [] - try: - types = structure_type_map() - for structure in load_structures(str(project_root))[1]: - definition = types.get(structure.type_id) - if definition is None or definition.group != "G": - continue - anchor = structure.chainage_m - start = structure.start_m if structure.start_m is not None else anchor - end = structure.end_m if structure.end_m is not None else anchor - if start is None or end is None: - continue - ranges.append((min(float(start), float(end)), max(float(start), float(end)))) - except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 - logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)") - return ranges + ford_pavement_ranges(project_root) - - -def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]: - """물넘이포장 범위(기준 ± 월류 폭/2) — 포장 필수 · 측구 없음(286-2). 판정은 `paved_at`.""" - if project_root is None: - return [] - sets = load_culvert_sets(project_root).items() - halves = [ - (at, float(s.get("span_m") or 0.0) / 2.0) - for at, s in sets - if s.get("type") == "ford_pavement" - ] - return [(at - half, at + half) for at, half in halves] - - -def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: - """세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)]. - - 구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼 - 내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로 - 관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다). - """ - if project_root is None: - return [] - drops: list[tuple[float, float]] = [] - try: - for chainage, spec in load_culvert_sets(project_root).items(): - if spec.get("type") != "ford": - continue - depth = float(spec.get("overflow_depth_m") or 0.0) - if depth > 0: - drops.append((chainage, depth)) - except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 - logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)") - return drops - - -def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: - """그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다.""" - for at, depth in drops: - if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M: - return depth - return 0.0 - - -# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7) -_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M - - -def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: - """이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시). - - 브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1). - """ - tol = _PAVED_EDGE_TOLERANCE_M - return any(start - tol <= chainage_m <= end + tol for start, end in ranges) - - # 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.** # # 브라우저 쪽 짝은 `B06_Section_Cross_Refresh.ts` 의 `PRESERVED_KEYS` 다(+ `status`· diff --git a/B06_Section/B06_Section_Router_Design_Ranges.py b/B06_Section/B06_Section_Router_Design_Ranges.py new file mode 100644 index 000000000..5dd62deef --- /dev/null +++ b/B06_Section/B06_Section_Router_Design_Ranges.py @@ -0,0 +1,113 @@ +"""B06 포장 · 세월교 구간 읽기와 판정 — `B06_Section_Router_Design.py` 에서 나눔(472-4 · 동작 같음). + +짝(349-0n): `pavement_ranges` · `ford_surface_drops` · `paved_at` · `ford_drop_at` ↔ +`B06_Section/logic/B06_Section_Cross_Enforce.ts` · 거울 시험 `test_349_0n_cross_enforce_mirror.py`. +부르는 쪽은 `B06_Section_Router_Design` 이름으로 씀(다시 내보냄 · 시험이 그 이름을 갈아 끼움). +""" + +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets +from common_util.common_util_structure_station import EDGE_TOLERANCE_M + +logger = logging.getLogger(__name__) + +# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과 +# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다. +_FORD_DROP_TOLERANCE_M = 0.02 + + +def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: + mapping: dict[float, bool] = {} + stations = longitudinal.get("stations") + for station in stations if isinstance(stations, list) else []: + suggested = station.get("pavement_suggested") + if isinstance(suggested, bool): + mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested + return mapping + + +def pavement_ranges(project_root: Path) -> list[tuple[float, float]]: + """포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수). + + 포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은 + 더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다. + 물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다. + 읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다. + """ + ranges: list[tuple[float, float]] = [] + try: + types = structure_type_map() + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.group != "G": + continue + anchor = structure.chainage_m + start = structure.start_m if structure.start_m is not None else anchor + end = structure.end_m if structure.end_m is not None else anchor + if start is None or end is None: + continue + ranges.append((min(float(start), float(end)), max(float(start), float(end)))) + except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 + logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)") + return ranges + ford_pavement_ranges(project_root) + + +def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]: + """물넘이포장 범위(기준 ± 월류 폭/2) — 포장 필수 · 측구 없음(286-2). 판정은 `paved_at`.""" + if project_root is None: + return [] + sets = load_culvert_sets(project_root).items() + halves = [ + (at, float(s.get("span_m") or 0.0) / 2.0) + for at, s in sets + if s.get("type") == "ford_pavement" + ] + return [(at - half, at + half) for at, half in halves] + + +def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: + """세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)]. + + 구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼 + 내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로 + 관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다). + """ + if project_root is None: + return [] + drops: list[tuple[float, float]] = [] + try: + for chainage, spec in load_culvert_sets(project_root).items(): + if spec.get("type") != "ford": + continue + depth = float(spec.get("overflow_depth_m") or 0.0) + if depth > 0: + drops.append((chainage, depth)) + except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 + logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)") + return drops + + +def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: + """그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다.""" + for at, depth in drops: + if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M: + return depth + return 0.0 + + +# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7) +_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M + + +def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: + """이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시). + + 브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1). + """ + tol = _PAVED_EDGE_TOLERANCE_M + return any(start - tol <= chainage_m <= end + tol for start, end in ranges) diff --git a/resources/tester/test_349_0n_cross_enforce_mirror.py b/resources/tester/test_349_0n_cross_enforce_mirror.py index f1508b96a..5834a690a 100644 --- a/resources/tester/test_349_0n_cross_enforce_mirror.py +++ b/resources/tester/test_349_0n_cross_enforce_mirror.py @@ -26,6 +26,7 @@ if str(ROOT) not in sys.path: 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 +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.B06_Section_Engine_Culvert import culvert_sets_from # noqa: E402 from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 @@ -157,7 +158,8 @@ def server(r) -> list[dict]: """서버 [확정] 차례 그대로 — 저장분(보정 전) → 포장 구간 → 세월교 하강.""" patch = pytest.MonkeyPatch() sets = culvert_sets_from(parse_pipe_points(PIPES)) - for module in (design_mod, tilt_mod): + # 구간 읽기는 472-4 에서 `_Router_Design_Ranges` 로 나눔 — 정본 읽기를 그 모듈에서 갈아 끼움. + for module in (ranges_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)) diff --git a/resources/tester/test_349_0p_fractional_station.py b/resources/tester/test_349_0p_fractional_station.py index 156bee2c9..3e2edb595 100644 --- a/resources/tester/test_349_0p_fractional_station.py +++ b/resources/tester/test_349_0p_fractional_station.py @@ -27,6 +27,7 @@ from test_349_0n_cross_enforce_mirror import OFFSETS, _stations, _z # noqa: E40 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 +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.B06_Section_Engine_Culvert import culvert_sets_from # noqa: E402 from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 @@ -109,7 +110,8 @@ def server(r) -> list[dict]: """서버 [확정] — 저장분(입력 없는 측점은 config 암 경계) → 포장 구간 → 세월교 하강.""" patch = pytest.MonkeyPatch() sets = culvert_sets_from(parse_pipe_points(PIPES)) - for module in (design_mod, tilt_mod): + # 구간 읽기는 472-4 에서 `_Router_Design_Ranges` 로 나눔 — 정본 읽기를 그 모듈에서 갈아 끼움. + for module in (ranges_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)) From 4752cd223fcf3ea9e86c4f8295ddcd78641af7ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:08:37 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(472-4=20=E2=91=A1=20Node=20=EC=9E=85?= =?UTF-8?q?=EA=B5=AC):=20cross.design=20=EC=8B=A4=ED=96=89=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20TS=20computeCrossDesign=20=EC=9D=84=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=AC=B6=EC=9D=8C=EC=9C=BC=EB=A1=9C(=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=EC=8D=AC=20=ED=82=A4=20=EB=B0=9B=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=AA=BB=20=EC=84=B8=EC=9A=B4=20=EC=9E=90=EB=A6=AC=20error)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 아직 부르는 곳 없음 — 다음 커밋에서 [저장] · [확정] 재계산이 씀 - 고정 자료 견줌: [저장] 59 회 · 노선 체인 179 회 — 면적 · 계획고 · 노면 끝 0 차 · 설계선 점 13 · 34 곳 1e-4 m(반올림 동점) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/logic/design_calc_node.ts | 76 ++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 1708bcc3f..130e0546d 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -9,7 +9,9 @@ * context?: { earthwork_conversion · natural_spoil_min_ground_slope · * haul_equipment_limits · 채집석 · 구조물 잔토(B08) } · * excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m · - * has_foundation }] } } } + * has_foundation }] } · + * cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) · + * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } } } * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). @@ -17,6 +19,9 @@ * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; +import type { BermSpec } from "@util/common_util_cross_berm"; +import { computeCrossDesign } from "@util/common_util_cross_design"; +import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design"; import { pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM } from "@util/common_util_excavation"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import type { MassHaulResult } from "@util/common_util_mass_haul"; @@ -58,6 +63,63 @@ interface CalcInputs { has_foundation?: boolean; }>; }; + cross_design?: { + standard: CrossDesignOptions["standard"]; + stations?: Array<{ + samples: CrossGroundSample[]; + design_elevation_m: number | null; + options: Record; + }>; + }; +} + +/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */ +const CROSS_OPTION_KEYS: Record = { + ground_type: "groundType", + section_mode: "sectionMode", + ditch_side: "ditchSide", + ditch_type: "ditchType", + paved: "paved", + rock_boundary_offset_m: "rockBoundaryOffsetM", + two_stage_slope: "twoStageSlope", + cut_slope_ratio: "cutSlopeRatio", + ditch_enabled: "ditchEnabled", + ditch_choice: "ditchChoice", + surface_drop_m: "surfaceDropM", + plan_radius_m: "planRadiusM", + curve_outer_side: "curveOuterSide", + curve_widening_m: "curveWideningM", + shoulder_extend_left_m: "shoulderExtendLeftM", + shoulder_extend_right_m: "shoulderExtendRightM", + tilted: "tilted", + tilt_pct: "tiltPct", +}; + +/** 파이썬 BermSpec(NamedTuple → dict) → TS BermSpec. */ +function bermOf(value: unknown): BermSpec | null { + if (!value || typeof value !== "object") return null; + const berm = value as Record; + return { + widthM: berm.width_m as number, + intervalM: berm.interval_m as number, + slopeDeg: berm.slope_deg as number, + depthM: berm.depth_m as number, + depthLeftM: berm.depth_left_m ?? null, + depthRightM: berm.depth_right_m ?? null, + }; +} + +function crossOptions( + raw: Record, + standard: CrossDesignOptions["standard"], +): CrossDesignOptions { + const options: Record = { standard }; + for (const [key, value] of Object.entries(raw)) { + if (key === "berm") options.berm = bermOf(value); + else if (key in CROSS_OPTION_KEYS) options[CROSS_OPTION_KEYS[key]] = value; + else throw new Error(`모르는 횡단 설계 키: ${key}`); + } + return options as unknown as CrossDesignOptions; } /** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로. */ @@ -117,6 +179,18 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { ), })), }), + // 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐). + "cross.design": () => { + const batch = inputs.cross_design; + return (batch?.stations ?? []).map((station) => { + try { + const options = crossOptions(station.options, batch!.standard); + return computeCrossDesign(station.samples, station.design_elevation_m, options); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }); + }, // 편집 전 정본 — 계획선 샘플은 저장분 그대로(corridor 묶음과 같은 값) · 측점 둘 미만이면 null. "corridor.mesh": () => { const detail = inputs.detail; From 3a64f9a812aebd57e23bec152a8e68ef1c909531 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:21:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(472-4=20=E2=91=A1=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=A7=9D=20=EB=AA=A8=EC=9C=BC=EA=B8=B0):=20[=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=20=C2=B7=20[=ED=99=95=EC=A0=95]=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=EC=84=A0=20=C2=B7=20=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=C2=B7=20=EC=86=8C=EB=8B=A8=20=EA=B5=AC=EA=B0=84=EC=9D=84=20Nod?= =?UTF-8?q?e=20=EC=9E=85=EA=B5=AC=20=ED=95=9C=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=EB=A1=9C(C1=20=C2=B7=20C2=20=C2=B7=20C4=20=C2=B7=20X3=20?= =?UTF-8?q?=ED=92=80=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Router_Design enforce 셋 · 넓힘 뒤 설계 — 파이썬은 입력 조립만 · 측점 묶음을 Node 한 번(B06_Section_Engine_Design_Node · cross.design) · Node 못 부르면 파이썬 짝 - 소단 구간 셈을 화면 파일에서 common_util_berm_spans.ts 로 · 서버도 같은 TS(cross.berm_spans) · Persist 가 다시 내보냄 - 값: 고정 자료 기준값 0 차(면적 · 계획고 · 누가토량 −17,570.7) · 설계선 점 13 곳 1e-4 m(반올림 동점 · 브레인 승인) — 기준값 시험에 판정 더함 - 시험: compute_cross_design 갈아 끼우던 시험 다섯을 묶음 자리로(까닭: [저장] 다시 셈이 묶음으로 부름) · 소단 글 시험 자리 옮김 - 등록부 · 위키 계산 목록 · B06 지침 줄 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/logic/design_calc_node.ts | 13 ++- .../design/logic/design_calc_registry.ts | 22 ++-- B06_Section/B06_Section_Engine_Design_Node.py | 104 +++++++++++++++++ .../B06_Section_Engine_SampleExtend.py | 26 +++-- B06_Section/B06_Section_Router_Design.py | 90 ++++++++------- B06_Section/B06_Section_UI_Page_Persist.ts | 107 ++---------------- B06_Section/CLAUDE.md | 1 + common_util/common_util_berm_spans.ts | 107 ++++++++++++++++++ common_util/common_util_calc_runner.py | 2 + .../wiki/concepts/design_page/calculations.md | 14 +-- resources/tester/test_138_cross_detail.py | 5 +- .../tester/test_178_2_berm_fill_mirror.py | 3 +- resources/tester/test_260_note_cad.py | 5 +- resources/tester/test_472_1_golden.py | 20 ++++ resources/tester/test_472_3_calc_registry.py | 10 +- resources/tester/test_472_3_calc_runner.py | 33 ++++++ .../tester/test_b06_cross_design_mirror.py | 2 + .../tester/test_b06_pavement_and_ford.py | 5 +- .../test_b06_recompute_keeps_user_values.py | 7 +- resources/tester/test_user_values_survive.py | 7 +- 20 files changed, 406 insertions(+), 177 deletions(-) create mode 100644 B06_Section/B06_Section_Engine_Design_Node.py create mode 100644 common_util/common_util_berm_spans.ts diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 130e0546d..9a0047765 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -11,7 +11,8 @@ * excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m · * has_foundation }] } · * cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) · - * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } } } + * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } · + * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } } } * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). @@ -19,6 +20,7 @@ * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; +import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans"; import type { BermSpec } from "@util/common_util_cross_berm"; import { computeCrossDesign } from "@util/common_util_cross_design"; import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design"; @@ -63,6 +65,10 @@ interface CalcInputs { has_foundation?: boolean; }>; }; + berm?: { + structures: Parameters[0]; + chainages: number[]; + }; cross_design?: { standard: CrossDesignOptions["standard"]; stations?: Array<{ @@ -179,6 +185,11 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { ), })), }), + // 소단 구간(C4) — 구조물 정본 → 구간 · 측점마다 제원(겹치면 먼저 놓은 것). + "cross.berm_spans": () => { + const spans = bermSpansFromStructures(inputs.berm?.structures ?? []); + return { spans, at: (inputs.berm?.chainages ?? []).map((c) => bermSpecAt(spans, c)) }; + }, // 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐). "cross.design": () => { const batch = inputs.cross_design; diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index e69d3c7da..f692b269a 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -365,11 +365,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${U}common_util_cross_design.ts#computeCrossDesign`, - runner: `py:${B06}B06_Section_Engine_Design.py#compute_cross_design`, - until: "472-4", + runner: "node:cross.design", pair: `${B06}B06_Section_Engine_Design.py#compute_cross_design`, mirror: ["test_b06_cross_design_mirror", "test_178_2_berm_fill_mirror"], - note: "파이썬 짝은 B07 · 추천 · Route_Save · B05 확정도 부름 — 짝 남음", + note: "[저장] · [확정] 다시 셈 = Node(472-4 ②) · 파이썬 짝 남음(B07 · 추천 · Route_Save · B05 확정 · 넓힘 찾기) · 알려진 차 = 설계선 점 반올림 동점 1e-4 m", }, { id: "cross.areas", @@ -380,11 +379,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${U}common_util_cross_design_areas.ts#splitCutAreas`, - runner: `py:${B06}B06_Section_Engine_Areas.py#_split_cut_areas`, - until: "472-4", + runner: "node:cross.design", pair: `${B06}B06_Section_Engine_Areas.py#_split_cut_areas`, mirror: ["test_b06_cross_design_mirror"], - note: "X3 — 표준 측점 = 파이썬 · 구조물 측점 = Node(structure.layouts)", + note: "설계선과 한 셈(cross.design) · X3 한 갈래 — 표준 측점 · 구조물 측점(structure.layouts) 모두 Node", }, { id: "cross.enforce", @@ -398,6 +396,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`, until: "472-4", pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`, + note: "구간 판정 = 파이썬 짝(Router_Design_Ranges) · 다시 세운 설계 = Node cross.design", mirror: ["test_349_0n_cross_enforce_mirror"], }, { @@ -408,12 +407,11 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ outputs: ["소단 구간"], site: "browser_now", stage: null, - ts: `${B06}B06_Section_UI_Page_Persist.ts#bermSpansFromStructures`, - runner: `py:${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`, - until: "472-4", + ts: `${U}common_util_berm_spans.ts#bermSpansFromStructures`, + runner: "node:cross.berm_spans", pair: `${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`, mirror: ["test_272_berm_source"], - note: "TS 가 화면 파일 안 — common_util 로(472-4 ②)", + note: "[저장] 다시 셈 = Node(472-4 ②) · 파이썬 짝은 Node 못 부를 때만", }, { id: "cross.berm_slope", @@ -424,9 +422,9 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${U}common_util_cross_berm.ts#cutProfilePoints`, - runner: `py:${U}common_util_cross_berm.py#cut_profile_points`, - until: "472-4", + runner: "node:cross.design", pair: `${U}common_util_cross_berm.py#cut_profile_points`, + note: "설계선 셈 안(cross.design) · 파이썬 짝 남음(cross.design 과 같은 까닭)", mirror: ["test_178_2_berm_fill_mirror"], }, { diff --git a/B06_Section/B06_Section_Engine_Design_Node.py b/B06_Section/B06_Section_Engine_Design_Node.py new file mode 100644 index 000000000..2f67bc54a --- /dev/null +++ b/B06_Section/B06_Section_Engine_Design_Node.py @@ -0,0 +1,104 @@ +"""횡단 설계선 · 면적 · 소단 구간을 서버 Node 입구로(PLAN 472-4 ② · 등록부 `cross.design` · +`cross.berm_spans`) — 브라우저와 같은 TS(`common_util_cross_design` · `common_util_berm_spans`). + +파이썬은 입력 조립만 — 측점마다 `compute_cross_design` 키를 모아 Node 한 번에 보냄. +Node 를 못 부르면 파이썬 짝으로(거울 시험 `test_b06_cross_design_mirror` · `test_272_berm_source` · +알려진 차 = 설계선 점 반올림 동점 1e-4 m — 파이썬 round 짝수 쪽 · TS 위쪽). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from B06_Section.B06_Section_Engine_Design import _resolve_group, compute_cross_design +from common_util.common_util_calc_runner import run_calc + +logger = logging.getLogger(__name__) + +# 한 측점 입력 = (지반 표본, 계획고, `compute_cross_design` 키 — standard 뺌) +DesignJob = tuple[list[dict[str, Any]], float | None, dict[str, Any]] + + +def full_standard(standard: dict[str, Any] | None) -> dict[str, Any]: + """config 기본값 위에 저장분을 덮은 표준 횡단 한 벌 — TS 는 config 를 모름(입력으로 받음).""" + out: dict[str, Any] = {} + for key in ("soil", "rock", "paved"): + g = _resolve_group(key, standard) + out[key] = { + "road_width_m": g["road_width_m"], + "shoulder_left_m": g["shoulder_left_m"], + "shoulder_right_m": g["shoulder_right_m"], + "ditch": { + "top_width_m": g["ditch_top_width_m"], + "bottom_width_m": g["ditch_bottom_width_m"], + "depth_m": g["ditch_depth_m"], + }, + "ditch_l_type": {"width_m": g["l_ditch_width_m"], "depth_m": g["l_ditch_depth_m"]}, + "cross_slope_pct": {"min": g["cross_slope_pct"]}, + "fill_slope_ratio": g["fill_slope_ratio"], + "cut_slope_ratio": g["cut_slope_ratio"], + "pavement_thickness_m": g["pavement_thickness_m"], + } + return out + + +def _python(job: DesignJob, standard: dict[str, Any] | None) -> dict[str, Any] | None: + samples, elevation, options = job + try: + return compute_cross_design(samples, elevation, standard=standard, **options) + except (ValueError, KeyError): + return None + + +def compute_cross_designs( + jobs: list[DesignJob], standard: dict[str, Any] | None +) -> list[dict[str, Any] | None]: + """측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리).""" + if not jobs: + return [] + stations = [ + { + "samples": samples, + "design_elevation_m": elevation, + "options": { + key: (value._asdict() if key == "berm" and value is not None else value) + for key, value in options.items() + }, + } + for samples, elevation, options in jobs + ] + results = run_calc( + ["cross.design"], + {"cross_design": {"standard": full_standard(standard), "stations": stations}}, + ) + if results is None: + logger.warning("Node 횡단 설계 실패 — 파이썬 짝으로 셈(%d 측점)", len(jobs)) + return [_python(job, standard) for job in jobs] + return [None if "error" in row else row for row in results["cross.design"]] + + +def berm_specs_at( + project_root: Path, chainages: list[float] +) -> list[dict[str, float] | None] | None: + """측점마다 소단 제원(구조물 정본 구간) · None = 정본을 못 읽음(부르는 쪽이 저장분 그대로).""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans + + try: + structures = [ + item.model_dump(include={"type_id", "start_m", "end_m", "chainage_m", "options"}) + for item in load_structures(str(project_root))[1] + ] + except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬 + logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)") + return None + results = run_calc( + ["cross.berm_spans"], {"berm": {"structures": structures, "chainages": chainages}} + ) + if results is not None: + return results["cross.berm_spans"]["at"] + logger.warning("Node 소단 구간 실패 — 파이썬 짝으로 셈") + spans = load_berm_spans(project_root) + return None if spans is None else [berm_span_at(spans, c) for c in chainages] diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 333bf609c..4b69f70ce 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -118,6 +118,7 @@ def extend_unclosed_sections( sampler: Any, ) -> int: """저장 설계가 미교차인 측점을 넓혀 샘플·설계를 자리에서 갈고 횡단 파일에 남긴다. 넓힌 수.""" + from B06_Section.B06_Section_Engine_Design_Node import compute_cross_designs from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, @@ -132,23 +133,21 @@ def extend_unclosed_sections( return 0 drops = ford_surface_drops(project_root) cross_dir = project_root / "B06_Section" / "cross_sections" - changed = 0 + done: list[tuple[dict[str, Any], dict[str, Any], float, list[dict[str, Any]], dict, tuple]] = [] for section in cross_sections: design = section.get("design") if not isinstance(design, dict) or not design.get("slope_unclosed"): continue chainage = float(section.get("chainage_m", 0.0)) - def compute(samples: list[dict[str, Any]], design=design, chainage=chainage) -> dict: - return compute_cross_design( - samples, - design_elevation_from_longitudinal(longitudinal, chainage), + try: + elevation = design_elevation_from_longitudinal(longitudinal, chainage) + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -162,6 +161,12 @@ def extend_unclosed_sections( **stored_shoulder_extend(design), # 노견 확장(139-6) **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) ) + except (ValueError, KeyError): + continue + + # 넓힘 찾기 = 파이썬 짝(되풀이 셈) · 넓힌 뒤 정본 설계 = Node 한 번(472-4 ② · 아래) + def compute(samples: list[dict[str, Any]], z=elevation, opts=options) -> dict: + return compute_cross_design(samples, z, standard=standard, **opts) try: result = extend_unclosed( @@ -174,7 +179,14 @@ def extend_unclosed_sections( samples, info = result if not info["left_m"] and not info["right_m"]: continue # 첫 걸음부터 지표면 밖 — 샘플이 그대로라 갈 것이 없음 - recomputed = compute(samples) + done.append((section, design, chainage, samples, info, (samples, elevation, options))) + results = compute_cross_designs([job for *_rest, job in done], standard) + changed = 0 + for (section, design, chainage, samples, info, _job), recomputed in zip( + done, results, strict=True + ): + if recomputed is None: + continue for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): if design.get(key) is not None: recomputed[key] = design[key] diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 29231cc3e..e6b84416b 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -15,6 +15,11 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, stored_tilt, ) +from B06_Section.B06_Section_Engine_Design_Node import ( + DesignJob, + berm_specs_at, + compute_cross_designs, +) from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges # 포장 · 세월교 구간(나눈 파일) — 이 모듈 이름으로 다시 내보냄(부르는 곳 · 시험 갈아 끼움 그대로). @@ -130,6 +135,26 @@ def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> di return standard if isinstance(standard, dict) else None +def apply_designs( + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]], standard: dict[str, Any] | None +) -> int: + """모은 측점을 Node 한 번(TS 짝 · 472-4 ②)으로 다시 세워 자리에서 갈아 끼움 · 바꾼 수. + + `design` = 승계 원천(사용자 조작값 · 상태) · 못 세운 측점은 저장분 그대로(옛 ValueError 자리). + """ + results = compute_cross_designs([job for _section, _design, job in jobs], standard) + changed = 0 + for (section, design, _job), recomputed in zip(jobs, results, strict=True): + if recomputed is None: + continue + for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): + if design.get(key) is not None: + recomputed[key] = design[key] + section["design"] = recomputed + changed += 1 + return changed + + def enforce_pavement_ranges( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -146,7 +171,7 @@ def enforce_pavement_ranges( ranges = pavement_ranges(project_root) tilts = tilt_ranges(project_root) ford_drops = ford_surface_drops(project_root) - changed = 0 + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = [] for section in cross_sections: design = section.get("design") if not isinstance(design, dict): @@ -157,15 +182,12 @@ def enforce_pavement_ranges( if bool(design.get("paved")) == wanted and stored_tilt(design) == tilt: continue try: - recomputed = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage), + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=wanted, - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -179,14 +201,11 @@ def enforce_pavement_ranges( **stored_shoulder_extend(design), # 노견 확장(139-6) **tilt, ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - recomputed[key] = design[key] - section["design"] = recomputed - changed += 1 - return changed + jobs.append((section, design, (section.get("samples", []), elevation, options))) + return apply_designs(jobs, standard) def enforce_design_lines( @@ -203,29 +222,23 @@ def enforce_design_lines( 확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계. 소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1). """ - from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans - ford_drops = ford_surface_drops(project_root) tilts = tilt_ranges(project_root) - spans = load_berm_spans(project_root) - changed = 0 - for section in cross_sections: - design = section.get("design") - if not isinstance(design, dict): - continue - chainage = float(section.get("chainage_m", 0.0)) - if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로 - design = {**design, "berm": berm_span_at(spans, chainage)} + targets = [section for section in cross_sections if isinstance(section.get("design"), dict)] + chainages = [float(section.get("chainage_m", 0.0)) for section in targets] + berms = berm_specs_at(project_root, chainages) # Node 소단 구간(C4) · None = 못 읽음 + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = [] + for index, (section, chainage) in enumerate(zip(targets, chainages, strict=True)): + design = section["design"] + if berms is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로 + design = {**design, "berm": berms[index]} try: - recomputed = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage), + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -239,14 +252,11 @@ def enforce_design_lines( **stored_shoulder_extend(design), **tilt_args(chainage, tilts), ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - recomputed[key] = design[key] - section["design"] = recomputed - changed += 1 - return changed + jobs.append((section, design, (section.get("samples", []), elevation, options))) + return apply_designs(jobs, standard) def enforce_ford_surface_drops( @@ -262,7 +272,7 @@ def enforce_ford_surface_drops( 월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다. """ drops = ford_surface_drops(project_root) - changed = 0 + jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = [] for section in cross_sections: design = section.get("design") if not isinstance(design, dict): @@ -273,15 +283,12 @@ def enforce_ford_surface_drops( if abs(wanted - stored) < 1e-6: continue try: - recomputed = compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal, chainage), + options = dict( ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), - standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -295,14 +302,11 @@ def enforce_ford_surface_drops( **stored_shoulder_extend(design), # 노견 확장(139-6) **stored_tilt(design), ) + elevation = design_elevation_from_longitudinal(longitudinal, chainage) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - recomputed[key] = design[key] - section["design"] = recomputed - changed += 1 - return changed + jobs.append((section, design, (section.get("samples", []), elevation, options))) + return apply_designs(jobs, standard) def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3d6e0b82b..2c3607452 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -25,12 +25,6 @@ import { writeState, } from "../A00_Common/b_page_state"; import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options"; -import { - BERM_DEFAULT_DEPTH_M, - BERM_DEFAULT_INTERVAL_M, - BERM_DEFAULT_SLOPE_DEG, - BERM_DEFAULT_WIDTH_M, -} from "@util/common_util_cross_berm"; import { crossDesignChoices, readBermDepths, @@ -43,7 +37,15 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; -import { catalogRow } from "../common_util/common_util_structure_catalog"; +// 소단 구간(C4) — common_util 로 옮김 · 부르는 곳 그대로(다시 내보냄). +import type { BermSpan } from "@util/common_util_berm_spans"; +export { + bermSpansFromStructures, + bermSpecAt, + isBermType, + type BermSessionSpec, + type BermSpan, +} from "@util/common_util_berm_spans"; import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys"; /** @@ -107,27 +109,6 @@ function persistKeyed(name: KeyedState, storageKey: string, values: Map catalogRow(typeId)?.shape === "berm"; - -/** - * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 - * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). - * - * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 - * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 - * 그 사본만 본다. - */ -export function bermSpansFromStructures( - structures: ReadonlyArray<{ - type_id: string; - start_m?: number | null; - end_m?: number | null; - chainage_m?: number | null; - options?: Record; - }>, -): BermSpan[] { - const spans: BermSpan[] = []; - for (const item of structures) { - if (!isBermType(item.type_id)) continue; - const anchor = item.chainage_m ?? item.start_m ?? null; - const start = item.start_m ?? anchor; - const end = item.end_m ?? anchor; - if (start === null || end === null) continue; - const options = item.options ?? {}; - const number = (key: string, fallback: number): number => { - const parsed = Number(options[key]); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - }; - const width = number("width_m", BERM_DEFAULT_WIDTH_M); - const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); - if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2) - // 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0 - const rawDepth = Number(options.depth_m); - const depth = - options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth) - ? BERM_DEFAULT_DEPTH_M - : Math.max(rawDepth, 0); - spans.push({ - start_m: Math.min(start, end), - end_m: Math.max(start, end), - width_m: width, - interval_m: interval, - slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), - depth_m: depth, - }); - } - return spans; -} - -/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ -export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { - const found = spans.find( - (span) => - chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && - chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, - ); - return found - ? { - width_m: found.width_m, - interval_m: found.interval_m, - slope_deg: found.slope_deg, - depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M, - } - : null; -} - /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/B06_Section/CLAUDE.md b/B06_Section/CLAUDE.md index c008e4dd5..9d4f5e231 100644 --- a/B06_Section/CLAUDE.md +++ b/B06_Section/CLAUDE.md @@ -12,6 +12,7 @@ ① 짝(ts ↔ py · 거울 시험): - `Engine_Design.py` ↔ `common_util_cross_design.ts` · `Engine_Areas.py` ↔ `common_util_cross_design_areas.ts` · `test_b06_cross_design_mirror.py` +- [저장] · [확정] 다시 셈(`Router_Design` enforce · 넓힘 뒤 설계 · 소단 구간)은 Node(`Engine_Design_Node.py` → design-calc `cross.design` · `cross.berm_spans`) · 파이썬 짝은 B07 · 추천 · Route_Save · Node 못 부를 때 - `Engine_Culvert.py` ↔ `common_util_culvert_sets.ts` · `logic/UI_Cross_Culvert_Const.ts` · `test_b06_culvert_sets_mirror.py` · `test_b06_pipe_wall_height_mirror.py` - `Engine_Structures_Wall.py` ↔ `common_util_structure_walls.ts` · `test_b06_structure_walls_mirror.py` - 지형 표본 = `common_util_surface_sampler` 하나(`Engine_SampleExtend` 도) diff --git a/common_util/common_util_berm_spans.ts b/common_util/common_util_berm_spans.ts new file mode 100644 index 000000000..58ad71e3f --- /dev/null +++ b/common_util/common_util_berm_spans.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * common_util_berm_spans.ts + * 소단 구간 — 구조물 목록(표 형상 berm)에서 구간 · 측점마다 제원(PLAN 472-4 C4 · 화면 파일 + * `B06_Section_UI_Page_Persist.ts` 에서 옮김 · 그쪽이 다시 내보냄). + * 브라우저(시설 줄 · 다시 셈)와 서버 Node 입구(`design_calc_node` · 등록부 `cross.berm_spans`)가 같은 셈. + * 파이썬 짝 = `B06_Section_Engine_Structures_Wall.py` `load_berm_spans` · `berm_span_at` + * (Node 를 못 부를 때만 · 거울 시험 `test_272_berm_source.py`). + * ========================================================================== */ + +import { + BERM_DEFAULT_DEPTH_M, + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "./common_util_cross_berm"; +import { catalogRow } from "./common_util_structure_catalog"; + +/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ +export interface BermSessionSpec { + width_m: number; + interval_m: number; + slope_deg: number; + /** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */ + depth_m: number; +} + +/** + * 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원. + * + * 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」). + * 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로 + * 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다. + */ +export interface BermSpan extends BermSessionSpec { + start_m: number; + end_m: number; +} + +/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */ +export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm"; + +/** + * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 + * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). + * + * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 + * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 + * 그 사본만 본다. + */ +export function bermSpansFromStructures( + structures: ReadonlyArray<{ + type_id: string; + start_m?: number | null; + end_m?: number | null; + chainage_m?: number | null; + options?: Record; + }>, +): BermSpan[] { + const spans: BermSpan[] = []; + for (const item of structures) { + if (!isBermType(item.type_id)) continue; + const anchor = item.chainage_m ?? item.start_m ?? null; + const start = item.start_m ?? anchor; + const end = item.end_m ?? anchor; + if (start === null || end === null) continue; + const options = item.options ?? {}; + const number = (key: string, fallback: number): number => { + const parsed = Number(options[key]); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const width = number("width_m", BERM_DEFAULT_WIDTH_M); + const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); + if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2) + // 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0 + const rawDepth = Number(options.depth_m); + const depth = + options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth) + ? BERM_DEFAULT_DEPTH_M + : Math.max(rawDepth, 0); + spans.push({ + start_m: Math.min(start, end), + end_m: Math.max(start, end), + width_m: width, + interval_m: interval, + slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: depth, + }); + } + return spans; +} + +/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ +export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { + const found = spans.find( + (span) => + chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && + chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, + ); + return found + ? { + width_m: found.width_m, + interval_m: found.interval_m, + slope_deg: found.slope_deg, + depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M, + } + : null; +} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index b54a08f8e..8ff1550cc 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -24,6 +24,8 @@ NODE_IDS = frozenset( "haul.plan", "excavation.trench", "corridor.mesh", + "cross.design", + "cross.berm_spans", } ) diff --git a/docs/wiki/concepts/design_page/calculations.md b/docs/wiki/concepts/design_page/calculations.md index 2fbf731b3..4055c0dc2 100644 --- a/docs/wiki/concepts/design_page/calculations.md +++ b/docs/wiki/concepts/design_page/calculations.md @@ -8,7 +8,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md # 계산 목록 — TS · 파이썬 짝 · Node 묶음 · 실행 자리 -- Node 묶음 — sc = server-calc · co = corridor · ex = b07-extent · 다시 지음 `npm run -s build:server-calc|corridor|b07-extent`. +- Node 묶음 — sc = server-calc · co = corridor · ex = b07-extent · dc = design-calc(등록부 입구 `A00_Common/design/logic/design_calc_node.ts` · 파이썬 `common_util_calc_runner.run_calc`) · 다시 지음 `npm run -s build:server-calc|corridor|b07-extent|design-calc`. - 실행 자리 — 짝 = 브라우저 즉시 TS + [저장] 정본 파이썬 · ② = 같은 TS 를 브라우저 · Node 둘 다 · 브라우저 = 입력 도구 · 표시만 · 서버 = 무거운 GIS · 추천. - 줄 더함 · 바꿈 = 새 계산 · 짝 · 묶음이 바뀐 커밋에서. @@ -34,11 +34,11 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md | P6 | 옮긴 시설 꼭짓점 세움 | `logic/B05_Profile_Grade_Seat.ts:32` · `common_util_structure_station.ts:356` | `B05_Profile_Router_Confirm.py:220 · 306` | — | 짝 · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | | P7 | 직선화 · 자유지정 · 쉬프트 · 시종점 | `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/design_profile_free.ts` · `logic/B05_Profile_UI_Profile_Pivot.ts` · `logic/B05_Profile_EndOffsets.ts` | — | — | 브라우저(편집값만 저장 → P2) | | P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` · 경고 글 `logic/B05_Profile_UI_Profile_Alignment.ts` buildAlignment | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` · 경고 글 `B05_Profile_Engine_Grade_Alignment.py` build_alignment | — | 짝 · [저장] · [확정] = `B06_Section_Server_Calc_Prebuild.py` refresh_alignment 가 경고만 다시 씀(기하 그대로) · `test_442_2_grade_limit_mirror` · `test_472_save_warnings` | -| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195` | ex | 짝 · `test_b06_cross_design_mirror` | -| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | sc · ex | 짝 · 정본 = 표준 측점 파이썬 · 구조물 측점 Node | -| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design.py` · `B06_Section_Engine_Tilt.py` | — | 짝 · `test_349_0n_cross_enforce_mirror` | -| C4 | 소단 구간(구조물 → 구간) | `B06_Section_UI_Page_Persist.ts:157`(시설 줄이 부름) | `B06_Section_Engine_Structures_Wall.py:124` | — | 짝 · `test_272_berm_source` | -| C5 | 소단 사면 점 | `common_util_cross_berm.ts` | `common_util_cross_berm.py` | sc · co · ex | 짝 · `test_178_2_berm_fill_mirror` | +| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195`(B07 · 추천 · Route_Save · B05 확정 · 넓힘 찾기) | dc · ex | ② · [저장] · [확정] 다시 셈 = Node(`B06_Section_Engine_Design_Node.py` 측점 묶음 · 파이썬은 입력 조립) · `test_b06_cross_design_mirror` · `test_472_1_golden` 설계선 점 1e-4 | +| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | dc · sc · ex | ② · C1 과 한 셈 · 정본 = 표준 · 구조물 측점 모두 Node | +| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design_Ranges.py` · `B06_Section_Engine_Tilt.py` | dc(다시 세운 설계) | 구간 판정 짝 · `test_349_0n_cross_enforce_mirror` | +| C4 | 소단 구간(구조물 → 구간) | `common_util_berm_spans.ts`(시설 줄 · 다시 셈이 부름) | `B06_Section_Engine_Structures_Wall.py:124`(Node 못 부를 때만) | dc | ② · `test_272_berm_source` · `test_472_3_calc_runner` | +| C5 | 소단 사면 점 | `common_util_cross_berm.ts` | `common_util_cross_berm.py` | dc · sc · co · ex | C1 과 한 셈 · `test_178_2_berm_fill_mirror` | | C6 | 맹암거 얹기 · 기울임 | `common_util_structure_underdrain.ts` | `B06_Section_Engine_Tilt.py` | — | 짝 · 거울 시험 없음 | | C7 | 반폭 맞춤 · 성토 5 m 경고 | `logic/B06_Section_UI_Cross_Fit.ts` · `logic/B06_Section_UI_Cross_FillSlope_Warn.ts` | — | ex(Fit) | 브라우저 | | F1 | 시설 따라감(노선 바뀜) | `common_util_route_follow.ts:291` · `logic/B05_Profile_Pipeline_Follow.ts` · `logic/B05_Profile_Structure_Follow.ts` | `common_util_route_follow.py:120` | — | 짝 · `test_410_follow_xy` · `test_344_2_route_pairs` | @@ -64,7 +64,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md |---|---|---|---|---| | X1 | 단면 모양 기본값 | `"left_cut"`(`B06_Section_Cross_Refresh.ts:438`) | `default_section_modes`(`B06_Section_Router_Design.py:402`) | 사용자 협의 · 손대지 않음 | | X2 | 지반 표본 출처 · 입력 선 | 격자 `.bin` · `/route/plan` planned 선 | npz `sample_xy` · 저장 경로 폴리라인 | 노선 초안 없는 차례 = 정본 표본 유지로 막음 | -| X3 | 단면적 두 갈래 | 전부 TS | 표준 측점 파이썬 · 구조물 측점 Node | 1e-4 ㎡ 차 | +| X3 | 단면적 두 갈래 | 전부 TS | 모두 Node(472-4 ②) | 풀림 · 남은 차 = 설계선 점 반올림 동점 1e-4 m(파이썬 round 짝수 쪽 · TS 위쪽) | | X4 | 평면 발자국 | TS PlanFootprint | B07 파이썬 PlanSlope | 거울 시험 | | X5 | 경고 기준표 | `B05_Profile_UI_Panel_Fields.ts` 안 표 | `FOREST_ROAD_PROFILE_CRITERIA` | 거울 시험 · 자료 두 벌 | | X6 | 노선 바뀜 넘기는 칸 | `B06_Section_Station_Keys.ts:46` 근처 | `B05_Profile_Route_Save.py:275-279` CARRY_INPUT_KEYS · ROUTE_CARRY_KEYS | 손으로 맞춘 목록 둘 · 거울 없음 | diff --git a/resources/tester/test_138_cross_detail.py b/resources/tester/test_138_cross_detail.py index 2944db779..82c6d8272 100644 --- a/resources/tester/test_138_cross_detail.py +++ b/resources/tester/test_138_cross_detail.py @@ -42,8 +42,11 @@ def test_글은_재계산에서도_남는다(monkeypatch): monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 monkeypatch.setattr( - design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + design_mod, + "compute_cross_designs", + lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs], ) sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}] assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py index 1de2e3061..daf1ae1de 100644 --- a/resources/tester/test_178_2_berm_fill_mirror.py +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -202,7 +202,8 @@ def test_좌측_패널_칸_구간_깊이_나름(): """옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴). 좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`).""" - source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + # 소단 구간 셈은 472-4 C4 에서 `common_util_berm_spans.ts` 로 옮김 + source = (ROOT / "common_util" / "common_util_berm_spans.ts").read_text(encoding="utf-8") assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") assert "depthM: spec.depth_m" in refresh diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 569535660..e7b4b8cf8 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -59,8 +59,11 @@ def test_CAD_칸은_재계산에서도_남는다(monkeypatch): monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 monkeypatch.setattr( - design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + design_mod, + "compute_cross_designs", + lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs], ) sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 diff --git a/resources/tester/test_472_1_golden.py b/resources/tester/test_472_1_golden.py index aff996468..79ae0e1b1 100644 --- a/resources/tester/test_472_1_golden.py +++ b/resources/tester/test_472_1_golden.py @@ -19,6 +19,7 @@ X3 면적 두 갈래(구조물 = 관 351 하나 · 두 길 다 Node TS 로 셈) from __future__ import annotations +import copy import json from dataclasses import dataclass @@ -131,6 +132,25 @@ def test_두_길_사이_알려진_차만(got, pair) -> None: assert not gone, f"안 나타난 알려진 차 {gone} — KNOWN 에서 지움" +def test_저장_설계선_점_정본과_1e4_안(tmp_path) -> None: + """[저장] 다시 셈 설계선(472-4 ② = Node TS) — 정본과 점 수 · offset 같음 · 고도 1e-4 m 안. + + 알려진 차 = 반올림 동점(파이썬 round 짝수 쪽 · TS 위쪽) 13 점(브레인 10-19 승인). + """ + detail = copy.deepcopy(paths.read("detail.json")) + before = {s["chainage_m"]: s["design"]["design_line"] for s in detail["cross_sections"]} + sections, _ = paths._save_tail(detail, paths.stage_project(tmp_path / "project")) + moved = 0 + for section in sections: + old, new = before[section["chainage_m"]], section["design"]["design_line"] + assert [p["offset_m"] for p in new] == [p["offset_m"] for p in old], section["chainage_m"] + for a, b in zip(old, new, strict=True): + gap = abs(a["elevation_m"] - b["elevation_m"]) + assert gap <= 1e-4 + 1e-9, (section["chainage_m"], a, b) + moved += gap > 0 + assert moved == 13, f"반올림 동점 점 수 {moved} — 알려진 차(13)와 다름" + + def test_화면_참값() -> None: stored = GOLDEN["stored"] assert stored["final_cumulative_m3"] == -17570.7 diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index a11287b51..6541f52a6 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -40,6 +40,8 @@ NODE_IDS = { "haul.plan", "excavation.trench", "corridor.mesh", + "cross.design", + "cross.berm_spans", } pytestmark = pytest.mark.skipif( @@ -200,7 +202,7 @@ def test_id_마다_실행기_있음(loaded) -> None: if row["site"] == "browser_only": ok = kind == "browser" and _has_name(ref) elif kind == "node": - ok = ref == row["id"] and ref in NODE_IDS + ok = ref in NODE_IDS # 한 셈에서 나오는 값(면적 · 소단 사면)은 그 셈의 id else: ok = kind == "py" and _has_name(ref) ok = ok and ("until" not in row or kind == "py") @@ -209,8 +211,10 @@ def test_id_마다_실행기_있음(loaded) -> None: if not ok: bad.append(row["id"]) assert bad == [] - node_rows = {row["id"] for row in loaded["registry"] if row["runner"].startswith("node:")} - assert node_rows == NODE_IDS + node_runs = { + row["runner"][5:] for row in loaded["registry"] if row["runner"].startswith("node:") + } + assert node_runs == NODE_IDS def test_묶음_STAGE_DEPS_등록부_입력과_같음(loaded) -> None: diff --git a/resources/tester/test_472_3_calc_runner.py b/resources/tester/test_472_3_calc_runner.py index 98f3323ec..3243a35b6 100644 --- a/resources/tester/test_472_3_calc_runner.py +++ b/resources/tester/test_472_3_calc_runner.py @@ -68,3 +68,36 @@ def test_실행기_값_server_calc_와_같음() -> None: def test_Node_실행기_아닌_id_는_ValueError() -> None: with pytest.raises(ValueError, match="stations.list"): runner.run_calc(["masshaul.cumulative", "stations.list"], {}) + + +# ── 472-4 ② — 횡단 설계선 · 소단 구간 Node 길 ────────────────────────────────── + + +def test_소단_구간_Node_와_파이썬_짝_같음(tmp_path, monkeypatch) -> None: + from test_272_berm_source import STRUCTURE_CASES, _project + + from B06_Section import B06_Section_Engine_Design_Node as node_mod + from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans + + calls = [] + real = node_mod.run_calc + monkeypatch.setattr( + node_mod, "run_calc", lambda ids, inputs: calls.append(ids) or real(ids, inputs) + ) + chainages = [0.0, 5.0, 50.0, 65.0, 80.0, 100.0, 150.0, 250.0] + for index, structures in enumerate(STRUCTURE_CASES): + root = _project(tmp_path / f"c{index}", structures) + got = node_mod.berm_specs_at(root, chainages) + spans = load_berm_spans(root) + want = [berm_span_at(spans, c) for c in chainages] + assert got == [pytest.approx(w) if w else w for w in want], index + assert len(calls) == len(STRUCTURE_CASES) # 파이썬 물러섬 아님 + + +def test_횡단_설계_못_세운_측점은_None() -> None: + from B06_Section.B06_Section_Engine_Design_Node import compute_cross_designs + + ground = [{"offset_m": o, "elevation_m": 100.0 + 0.3 * o} for o in range(-20, 21)] + options = {"ground_type": "soil", "section_mode": "left_cut"} + got = compute_cross_designs([(ground, 100.0, options), (ground, None, options)], None) + assert got[0]["cut_area_m2"] > 0 and got[1] is None diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 7477c7430..ef6782a39 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -31,6 +31,8 @@ TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" # 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해 # 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다. +# 알려진 차(472-4 ②) — 반올림 동점(…5)에서 파이썬 round 는 짝수 쪽 · TS 는 위쪽이라 설계선 점이 +# 1e-4 m 갈릴 수 있음(이 시험 입력엔 없음 · [저장] 정본은 Node TS 쪽 · test_472_1_golden). _TOLERANCE = 1e-9 diff --git a/resources/tester/test_b06_pavement_and_ford.py b/resources/tester/test_b06_pavement_and_ford.py index 584c0c472..e01503854 100644 --- a/resources/tester/test_b06_pavement_and_ford.py +++ b/resources/tester/test_b06_pavement_and_ford.py @@ -146,8 +146,11 @@ def test_enforce_turns_paved_on_and_off(monkeypatch): monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 monkeypatch.setattr( - design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + design_mod, + "compute_cross_designs", + lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs], ) sections = [ {"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬 diff --git a/resources/tester/test_b06_recompute_keeps_user_values.py b/resources/tester/test_b06_recompute_keeps_user_values.py index c3d44b299..15357b95d 100644 --- a/resources/tester/test_b06_recompute_keeps_user_values.py +++ b/resources/tester/test_b06_recompute_keeps_user_values.py @@ -80,7 +80,12 @@ def _install_fakes(monkeypatch) -> dict: } monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)]) - monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 + monkeypatch.setattr( + design_mod, + "compute_cross_designs", + lambda jobs, standard: [fake_compute(s, z, standard=standard, **kw) for s, z, kw in jobs], + ) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) return seen diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 73f8ee72d..e2026d432 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -120,7 +120,12 @@ def _fake_engine(monkeypatch): return {"paved": True, "cut_area_m2": 1.0, "fill_area_m2": 2.0} monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)]) - monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute) + # 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움 + monkeypatch.setattr( + design_mod, + "compute_cross_designs", + lambda jobs, standard: [fake_compute(s, z, standard=standard, **kw) for s, z, kw in jobs], + ) monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) From aa0470278399e8085d7f39edea559d5e7e0e3c91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:27:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(472-4=20=E2=91=A1=20C6=20=EA=B1=B0?= =?UTF-8?q?=EC=9A=B8):=20=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=96=B9=EA=B8=B0?= =?UTF-8?q?=20TS=20=E2=86=94=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=EA=B1=B0?= =?UTF-8?q?=EC=9A=B8=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=98=AE=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=20=EB=A8=BC=EC=A0=80(=EC=95=8C=EB=A0=A4?= =?UTF-8?q?=EC=A7=84=20=EC=B0=A8=20=3D=20=EC=98=9B=20underdrain=20?= =?UTF-8?q?=EA=B1=B7=EC=9D=8C=20TS=20=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../design/logic/design_calc_registry.ts | 4 +- .../tester/helper_472_4_underdrain_entry.ts | 5 + .../tester/test_472_4_underdrain_mirror.py | 120 ++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 resources/tester/helper_472_4_underdrain_entry.ts create mode 100644 resources/tester/test_472_4_underdrain_mirror.py diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index f692b269a..958a1ef98 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -439,8 +439,8 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`, until: "472-4", pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`, - mirror: [], - note: "거울 시험 없음 — 옮기기 전 먼저", + mirror: ["test_472_4_underdrain_mirror"], + note: "얹기 = GET 상세(파이썬) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안", }, { id: "cross.fit", diff --git a/resources/tester/helper_472_4_underdrain_entry.ts b/resources/tester/helper_472_4_underdrain_entry.ts new file mode 100644 index 000000000..425117255 --- /dev/null +++ b/resources/tester/helper_472_4_underdrain_entry.ts @@ -0,0 +1,5 @@ +/* 472-4 C6 거울 헬퍼 — 맹암거 얹기 TS 짝(vite ssr) · test_472_4_underdrain_mirror.py 가 JSON 으로 받음. */ +export { + attachUnderdrainSpecs, + underdrainSpecsFrom, +} from "../../common_util/common_util_structure_underdrain"; diff --git a/resources/tester/test_472_4_underdrain_mirror.py b/resources/tester/test_472_4_underdrain_mirror.py new file mode 100644 index 000000000..947c16d13 --- /dev/null +++ b/resources/tester/test_472_4_underdrain_mirror.py @@ -0,0 +1,120 @@ +"""PLAN 472-4 ② C6 (요구 273) — 맹암거 얹기 거울: TS `common_util_structure_underdrain` ↔ 파이썬 +`B06_Section_Engine_Tilt.underdrains_from` · `attach_underdrain_specs`. + +못박는 것 + - 구조물 목록 → 제원(높이 · 경사 · 빈 값 · 0 · 음수 · 글 · 맹암거 아닌 종류) 두 쪽 같음. + - 기준 측점(±0.02 m) 얹기 · 얹은 수 같음. + - 알려진 차 하나 — 옛 `underdrain` 이 남은 측점: TS 는 걷음 · 파이썬은 둠 + (서버는 파일에서 새로 읽어 옛 값 없음 · Node 로 옮길 때 한 벌). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_underdrain_mirror.py -q +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _drain(at: float, **options) -> dict: + return { + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": at, + "options": options, + } + + +STRUCTURES = [ + _drain(40.0, culvert_height_m=1.2, culvert_slope_pct=3), + _drain(80.0, culvert_height_m="", culvert_slope_pct="2.5"), + _drain(120.0, culvert_height_m=0, culvert_slope_pct=-1), + _drain(160.0), + {"type_id": "berm", "placement": "interval", "start_m": 0.0, "end_m": 50.0, "options": {}}, +] +SECTIONS = [ + {"chainage_m": 40.01}, + {"chainage_m": 79.99}, + {"chainage_m": 120.0}, + {"chainage_m": 160.03}, # 0.02 밖 + {"chainage_m": 200.0, "underdrain": {"chainage_m": 200.0}}, # 옛 값 — 알려진 차 +] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("u4724") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_4_underdrain_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + check=True, + capture_output=True, + timeout=300, + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_4_underdrain_entry.js", bundle) + job = json.dumps({"structures": STRUCTURES, "sections": SECTIONS}) + script = ( + f"const m = await import({json.dumps(bundle.as_uri())});" + f"const job = {job};" + "const specs = m.underdrainSpecsFrom(job.structures);" + "const count = m.attachUnderdrainSpecs(job.sections, specs);" + "console.log(JSON.stringify({specs, count, sections: job.sections}));" + ) + out = subprocess.run( # noqa: S603 + ["node", "--input-type=module", "-e", script], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + return json.loads(out.stdout) + + +def _python() -> tuple[list[dict], int, list[dict]]: + specs = underdrains_from([StructureInstance(**item) for item in STRUCTURES]) + sections = copy.deepcopy(SECTIONS) + return specs, attach_underdrain_specs(sections, specs), sections + + +def test_제원_같음(ts) -> None: + specs, _count, _sections = _python() + assert ts["specs"] == specs + + +def test_얹기_같음_알려진_차_하나(ts) -> None: + _specs, count, sections = _python() + assert ts["count"] == count == 3 + assert ts["sections"][:4] == sections[:4] + # 알려진 차 — 옛 underdrain: TS 걷음 · 파이썬 둠 + assert "underdrain" not in ts["sections"][4] and "underdrain" in sections[4]