refactor(472-4 ① 꼭짓점 세움): 서버 꼭짓점 세움도 TS seatBornNodes 한 벌 — Node 입구 profile.seat

- 새 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 09:33:23 +09:00
co-authored by Claude Opus 5.5
parent 1f4f1c511b
commit cca08daa11
8 changed files with 137 additions and 86 deletions
+3 -1
View File
@@ -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<Parameters<typeof computeHaulPlan>[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<string, () => unknown> = {
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
"structure.layouts": () => {
const areas = structureAreaRows(sections);
@@ -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<string, () => 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 ?? []);
},
};
}
@@ -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",
+23 -77
View File
@@ -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(
+3 -3
View File
@@ -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` 짝. */
+1
View File
@@ -24,6 +24,7 @@ NODE_IDS = frozenset(
"haul.plan",
"excavation.trench",
"corridor.mesh",
"profile.seat",
}
)
@@ -40,6 +40,7 @@ NODE_IDS = {
"haul.plan",
"excavation.trench",
"corridor.mesh",
"profile.seat",
}
pytestmark = pytest.mark.skipif(
@@ -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 == []