From 3a64f9a812aebd57e23bec152a8e68ef1c909531 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:21:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(472-4=20=E2=91=A1=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=A7=9D=20=EB=AA=A8=EC=9C=BC=EA=B8=B0):=20[=EC=A0=80=EC=9E=A5?= =?UTF-8?q?]=20=C2=B7=20[=ED=99=95=EC=A0=95]=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=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: {})