From cca08daa11df007e812131c68c20170ab6b243aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:33:23 +0900 Subject: [PATCH] =?UTF-8?q?refactor(472-4=20=E2=91=A0=20=EA=BC=AD=EC=A7=93?= =?UTF-8?q?=EC=A0=90=20=EC=84=B8=EC=9B=80):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=BC=AD=EC=A7=93=EC=A0=90=20=EC=84=B8=EC=9B=80=EB=8F=84=20TS?= =?UTF-8?q?=20seatBornNodes=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20Node=20?= =?UTF-8?q?=EC=9E=85=EA=B5=AC=20profile.seat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 design_calc_node_follow.ts(따라감 몫 runner) · 입구는 두 줄만(import · 펼침) · 등록부 runner node:profile.seat · 실행기 NODE_IDS - Router_Confirm._seat_born_nodes = run_calc 겉(세울 자리 없으면 Node 안 부름) · 파이썬 셈 · 상수 걷음 - 시험 test_472_4_follow_node(옛 코드 빨강 2) · 427_1 · 459 거울 통과 · 472-3 NODE_IDS 에 profile.seat(cross.design 빨강은 ② 몫) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/logic/design_calc_node.ts | 4 +- .../design/logic/design_calc_node_follow.ts | 43 ++++++++ .../design/logic/design_calc_registry.ts | 9 +- B05_Profile/B05_Profile_Router_Confirm.py | 100 ++++-------------- B05_Profile/logic/B05_Profile_Grade_Seat.ts | 6 +- common_util/common_util_calc_runner.py | 1 + resources/tester/test_472_3_calc_registry.py | 1 + resources/tester/test_472_4_follow_node.py | 59 +++++++++++ 8 files changed, 137 insertions(+), 86 deletions(-) create mode 100644 A00_Common/design/logic/design_calc_node_follow.ts create mode 100644 resources/tester/test_472_4_follow_node.py diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 130e0546d..ea875446e 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -35,10 +35,11 @@ import { extraWallRows, structureAreaRows, } from "../../../B06_Section/B06_Section_Structure_Layouts"; +import { followRunners, type FollowInputs } from "./design_calc_node_follow"; type HaulOptions = NonNullable[2]>; -interface CalcInputs { +interface CalcInputs extends FollowInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -130,6 +131,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { let massHaul: MassHaulResult | null = null; const RUNNERS: Record unknown> = { + ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); diff --git a/A00_Common/design/logic/design_calc_node_follow.ts b/A00_Common/design/logic/design_calc_node_follow.ts new file mode 100644 index 000000000..ebecc1f64 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_follow.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_follow.ts + * 계산 Node 입구(`design_calc_node.ts`)의 따라감 몫(PLAN 472-4 ①) — 측점 열쇠 · 시설 따라감 · 추가 측점 · + * 꼭짓점 세움. 브라우저 묶음이 쓰는 같은 TS 를 부름 · 파이썬은 `common_util_calc_runner.run_calc` 로. + * + * profile.seat 입력 seat { ground_s · ground_z(저장 선형 표본) · policy(파이썬 `AlignmentPolicy.as_dict`) · + * base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`) + * ========================================================================== */ + +import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat"; +import type { + AlignmentNode, + AlignmentPolicy, +} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; + +export interface FollowInputs { + seat?: { + ground_s: number[]; + ground_z: number[]; + policy: AlignmentPolicy; + base_pvi: AlignmentNode[]; + base: AlignmentNode[]; + born: number[]; + held?: number[]; + }; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음). */ +export function followRunners(inputs: FollowInputs): Record unknown> { + return { + "profile.seat": () => { + const seat = inputs.seat; + if (!seat) return null; + const base = { + chainage: seat.ground_s, + ground: seat.ground_z, + policy: seat.policy, + basePvi: seat.base_pvi, + }; + return seatBornNodes(base, seat.base, seat.born, seat.held ?? []); + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index e69d3c7da..7bcccfe6a 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -320,11 +320,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "follow", ts: `${B05L}B05_Profile_Grade_Seat.ts#seatBornNodes`, - runner: `py:${B05}B05_Profile_Router_Confirm.py#_seat_born_nodes`, - until: "472-4", - pair: `${B05}B05_Profile_Router_Confirm.py#_seat_born_nodes`, - mirror: ["test_427_1_follow_seat", "test_459_route_seat_mirror"], - note: "Route_Save `_carry_profile` 도 같은 일", + runner: "node:profile.seat", + pair: null, + mirror: ["test_427_1_follow_seat", "test_459_route_seat_mirror", "test_472_4_follow_node"], + note: "파이썬 `Router_Confirm._seat_born_nodes` = Node 부름 겉(측점 병합 · Route_Save `_carry_profile`)", }, { id: "profile.tools", diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 71b2c1131..53ab71b6c 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -12,15 +12,12 @@ from typing import Any from uuid import UUID import aiomysql -import numpy as np from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( AlignmentPolicy, - build_curves, chainage_key, - evaluate, ) from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( @@ -42,6 +39,7 @@ from B06_Section.B06_Section_Repository import ( get_latest_section_options, get_longitudinal_section, ) +from common_util.common_util_calc_runner import run_calc from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path @@ -213,10 +211,6 @@ async def sync_uphill_overrides_into_designs( #: 반올림만 다른 같은 측점(m) — 서버가 관 측점을 0.01m 로 반올림. #: 화면 `applyIrregularStations` 0.006 과 짝. _SAME_STATION_M = 0.006 -#: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438). -_SEAT_GROUND_M = 0.01 -#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5). -_SEAT_KEEP_M = 0.001 def _seat_born_nodes( @@ -225,84 +219,36 @@ def _seat_born_nodes( born: list[float], held: list[float] = (), ) -> list[dict[str, Any]]: - """새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427). + """새 측점(`born`)에 자동 계획선 규칙(원지반 지남)으로 꼭짓점을 세움(427 · 433-5 · 438). - 이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · - 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그 - 밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 - 선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점 - 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 = - `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = - `test_427_1_follow_seat.py`. + 셈 = TS `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) 한 벌 — 서버 Node 입구 + `profile.seat`(472-4 ①). 지반 = 저장 선형 표본(`samples`) · 정책 = 파이썬 기본값을 채운 한 벌. + 세울 자리가 없으면(양 끝 밖 · 이미 꼭짓점 0.006m 안) Node 를 안 부름. 거울 시험 = + `test_427_1_follow_seat.py` · `test_472_4_follow_node.py`. """ samples = stored.get("samples") or [] if not base or len(samples) < 2: return base first, last = float(base[0]["chainage_m"]), float(base[-1]["chainage_m"]) - fresh = [ - value - for value in born - if first < value < last + if not any( + first < value < last and all(abs(value - float(node["chainage_m"])) >= _SAME_STATION_M for node in base) - ] - if not fresh: + for value in born + ): return base - ground_s = np.array([float(row["chainage_m"]) for row in samples], dtype=np.float64) - ground_z = np.array([float(row["ground_elevation_m"]) for row in samples], dtype=np.float64) - policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) - pre_s = np.array([float(node["chainage_m"]) for node in stored["base_pvi"]], dtype=np.float64) - pre_z = np.array([float(node["elevation_m"]) for node in stored["base_pvi"]], dtype=np.float64) - pre_curves, _warnings = build_curves(pre_s, pre_z, policy) - off = evaluate(pre_s, pre_z, pre_curves, pre_s) - np.interp(pre_s, ground_s, ground_z) - on_ground = float(np.median(np.abs(off))) < _SEAT_GROUND_M - - def seat_z(value: float) -> float: - if on_ground: - return float(np.interp(value, ground_s, ground_z)) - return float(evaluate(pre_s, pre_z, pre_curves, np.array([value]))[0]) - - rows = sorted( - [(float(node["chainage_m"]), float(node["elevation_m"]), node) for node in base] - + [(value, seat_z(value), None) for value in fresh], - key=lambda row: row[0], - ) - base_s = np.array([row[0] for row in rows], dtype=np.float64) - born_rows = np.array([row[2] is None for row in rows]) - # 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것 - target = np.where( - born_rows, - np.array([row[1] for row in rows], dtype=np.float64), - evaluate(pre_s, pre_z, pre_curves, base_s), - ) - can = np.array( - [ - row[2] is not None and all(abs(row[0] - value) >= _SAME_STATION_M for value in held) - for row in rows - ] - ) - free = born_rows.copy() - base_z = np.array([row[1] for row in rows], dtype=np.float64) - for _ in range(len(rows)): - for _ in range(20): - curves, _warnings = build_curves(base_s, base_z, policy) - error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0) - if float(np.max(np.abs(error))) < 1e-6: - break - base_z = base_z + error - # 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼) - curves, _warnings = build_curves(base_s, base_z, policy) - drift = ( - can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M) - ) - if not drift.any(): - break - free |= drift - return [ - node - if node is not None and not keep - else {**(node or {}), "chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} - for (s, _z, node), z, keep in zip(rows, base_z.tolist(), free.tolist()) - ] + seat = { + "ground_s": [float(row["chainage_m"]) for row in samples], + "ground_z": [float(row["ground_elevation_m"]) for row in samples], + "policy": AlignmentPolicy.from_dict(stored.get("policy") or {}).as_dict(), + "base_pvi": stored["base_pvi"], + "base": base, + "born": [float(value) for value in born], + "held": [float(value) for value in held], + } + results = run_calc(["profile.seat"], {"seat": seat}) + if results is None: + raise RuntimeError("꼭짓점 세움 실패 — 계산 Node 입구(profile.seat)") + return results["profile.seat"] def _follow_moved_stations( diff --git a/B05_Profile/logic/B05_Profile_Grade_Seat.ts b/B05_Profile/logic/B05_Profile_Grade_Seat.ts index d9fcd7330..eadf7f89b 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Seat.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Seat.ts @@ -4,7 +4,7 @@ * 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로. * 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). * - * 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes` + * 서버도 이 셈 — Node 입구 `profile.seat`(472-4 ① · 파이썬 `Router_Confirm._seat_born_nodes` 가 부름) · 세울 측점은 공용 `followMovedNodes` * (common_util_structure_station.ts)가 고름 · 거울 시험 = resources/tester/test_427_1_follow_seat.py. * ========================================================================== */ @@ -15,9 +15,9 @@ import { type AlignmentNode, } from "./B05_Profile_UI_Profile_Alignment"; -/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */ +/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) (438). */ const SEAT_GROUND_M = 0.01; -/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */ +/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5). */ const SEAT_KEEP_M = 0.001; /** `np.median` 짝. */ diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index b54a08f8e..d9eab93e0 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -24,6 +24,7 @@ NODE_IDS = frozenset( "haul.plan", "excavation.trench", "corridor.mesh", + "profile.seat", } ) diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 6fa2f52a0..51dd773ef 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -40,6 +40,7 @@ NODE_IDS = { "haul.plan", "excavation.trench", "corridor.mesh", + "profile.seat", } pytestmark = pytest.mark.skipif( diff --git a/resources/tester/test_472_4_follow_node.py b/resources/tester/test_472_4_follow_node.py new file mode 100644 index 000000000..5e9f90751 --- /dev/null +++ b/resources/tester/test_472_4_follow_node.py @@ -0,0 +1,59 @@ +"""PLAN 472-4 ① — 측점 열쇠 · 시설 따라감 · 추가 측점 · 꼭짓점 세움을 서버도 같은 TS(Node 입구)로 셈. + +파이썬 짝을 걷고 서버 부름 자리는 `common_util_calc_runner.run_calc`(등록부 id) 한 길 — + profile.seat `Router_Confirm._seat_born_nodes`(측점 병합 · 노선 저장 `_carry_profile`) +고정 자료 = `fixtures/golden_472/detail.json`(472-1 · 읽기만). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_follow_node.py -q +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Router_Confirm as confirm # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +DETAIL = json.loads((HERE / "fixtures" / "golden_472" / "detail.json").read_text(encoding="utf-8")) + + +@pytest.fixture +def calls(monkeypatch) -> list[list[str]]: + seen: list[list[str]] = [] + + def spy(ids, inputs): + seen.append(list(ids)) + return runner.run_calc(ids, inputs) + + monkeypatch.setattr(confirm, "run_calc", spy) + return seen + + +def test_꼭짓점_세움은_Node_입구(calls) -> None: + stored = DETAIL["longitudinal"]["profile_alignment"] + base = [node for node in stored["base_pvi"] if abs(node["chainage_m"] - 351.0) > 0.5] + got = confirm._seat_born_nodes(stored, base, [351.0]) + assert calls == [["profile.seat"]] + assert len(got) == len(base) + 1 + seated = next(node for node in got if abs(node["chainage_m"] - 351.0) < 1e-9) + old = next(node for node in stored["base_pvi"] if abs(node["chainage_m"] - 351.0) < 1e-9) + assert seated["elevation_m"] == pytest.approx(old["elevation_m"], abs=0.5) + + +def test_세울_자리_없으면_Node_안_부름(calls) -> None: + stored = DETAIL["longitudinal"]["profile_alignment"] + assert confirm._seat_born_nodes(stored, stored["base_pvi"], [351.0]) == stored["base_pvi"] + assert calls == []