fix(B05 종단 계획선): 관 · 구조물 옮김 때 새 측점에 꼭짓점 세움 — 원지반 지남 · 높이 고친 꼭짓점은 높이째 옮김 · 왕복하면 처음 값 (427-1)
- 서버 `_follow_moved_stations` · 새 `_seat_born_nodes` ↔ TS `followMovedNodes` 짝 - 짝 못 찾은 옛 측점 ↔ 새 측점 같은 수면 차례대로 짝 · 다른 꼭짓점 높이 그대로 - 거울 시험 test_427_1_follow_seat · 272-1 헬퍼 새 서명 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -12,9 +12,11 @@ import {
|
||||
maxChainageOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
|
||||
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
|
||||
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지우고 새 측점엔 세움(`followMovedNodes` · 427) ·
|
||||
* 늘 저장분에서 셈. */
|
||||
export function listedBase(
|
||||
storedBase: AlignmentBase | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
@@ -35,7 +37,12 @@ export function listedBase(
|
||||
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
|
||||
basePvi: followMovedNodes(
|
||||
storedBase,
|
||||
gone,
|
||||
born,
|
||||
Object.keys(readAlignment(detail.longitudinal)?.edits.station_offsets ?? {}),
|
||||
),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
|
||||
@@ -12,9 +12,15 @@ 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_Alignment import chainage_key
|
||||
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 (
|
||||
_load_pipe_points,
|
||||
@@ -201,6 +207,53 @@ async def sync_uphill_overrides_into_designs(
|
||||
_SAME_STATION_M = 0.006
|
||||
|
||||
|
||||
def _seat_born_nodes(
|
||||
stored: dict[str, Any], base: list[dict[str, Any]], born: list[float]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427).
|
||||
|
||||
다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
|
||||
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). TS 짝 = `followMovedNodes`
|
||||
(common_util_structure_station.ts) · 거울 시험 = `resources/tester/test_427_1_follow_seat.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
|
||||
and all(abs(value - float(node["chainage_m"])) >= _SAME_STATION_M for node in base)
|
||||
]
|
||||
if not fresh:
|
||||
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)
|
||||
rows = sorted(
|
||||
[(float(node["chainage_m"]), float(node["elevation_m"]), node) for node in base]
|
||||
+ [(value, float(np.interp(value, ground_s, ground_z)), None) for value in fresh],
|
||||
key=lambda row: row[0],
|
||||
)
|
||||
base_s = np.array([row[0] for row in rows], dtype=np.float64)
|
||||
target = np.array([row[1] for row in rows], dtype=np.float64)
|
||||
free = np.array([row[2] is None for row in rows])
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
base_z = target.copy()
|
||||
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
|
||||
return [
|
||||
node
|
||||
if node is not None
|
||||
else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for (s, _target, node), z in zip(rows, base_z.tolist())
|
||||
]
|
||||
|
||||
|
||||
def _follow_moved_stations(
|
||||
data: dict[str, Any], before: list[float], previous: list[float] | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
@@ -208,7 +261,10 @@ def _follow_moved_stations(
|
||||
|
||||
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
|
||||
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
|
||||
자리(272)만 옮기고 나머지는 지운다.
|
||||
자리(272)는 옮김. 옮긴 관 · 구조물(427 · 요구 255-2) — 짝 못 찾은 옛 측점과 새 측점이 같은
|
||||
수면 차례대로 짝 · 높이 고친 꼭짓점(`station_offsets`)은 높이째 · 편집째 옮기고, 나머지 새
|
||||
측점은 자동 계획선 규칙(원지반 지남 · `_seat_born_nodes`)으로 꼭짓점을 세움(되돌리면 처음
|
||||
값) · 짝 없는 옛 측점 꼭짓점은 지움.
|
||||
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
|
||||
아니면 `None`.
|
||||
"""
|
||||
@@ -236,6 +292,25 @@ def _follow_moved_stations(
|
||||
if near is None and snapped in after_keys and snapped not in before_keys:
|
||||
near = float(snapped)
|
||||
moves[chainage_key(old)] = None if near is None else chainage_key(near)
|
||||
# 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점 ↔ 새 측점(같은 수 · 차례대로)
|
||||
previous_keys = before_keys if previous is None else {chainage_key(v) for v in previous}
|
||||
taken = {key for key in moves.values() if key is not None}
|
||||
born = sorted(
|
||||
value
|
||||
for value in after
|
||||
if chainage_key(value) not in previous_keys and chainage_key(value) not in taken
|
||||
)
|
||||
lost = sorted((key for key, target in moves.items() if target is None), key=float)
|
||||
offsets = (stored.get("edits") or {}).get("station_offsets") or {}
|
||||
# 꼭짓점을 새로 세울 측점 — 짝이면 높이 안 고친 것만(편집 R 은 옮김) · 짝 없으면 새 측점 전부
|
||||
seat = born
|
||||
if lost and len(lost) == len(born):
|
||||
seat = []
|
||||
for old_key, value in zip(lost, born):
|
||||
moves[old_key] = chainage_key(value)
|
||||
if not any(abs(float(key) - float(old_key)) < _SAME_STATION_M for key in offsets):
|
||||
seat.append(value)
|
||||
reseat = {chainage_key(value) for value in seat}
|
||||
|
||||
def moved(value: float) -> tuple[bool, str | None]:
|
||||
"""옛 측점 0.006m 안이면 (참, 옮길 키 · 지우면 None) — 옛 글자(263.206)도 같은 측점(213-1).
|
||||
@@ -269,8 +344,11 @@ def _follow_moved_stations(
|
||||
base.append(node)
|
||||
continue
|
||||
changed = True
|
||||
if new_key is not None:
|
||||
if new_key is not None and new_key not in reseat:
|
||||
base.append({**node, "chainage_m": float(new_key)})
|
||||
seated = _seat_born_nodes(stored, base, seat)
|
||||
changed = changed or len(seated) != len(base)
|
||||
base = seated
|
||||
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
|
||||
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
|
||||
rows = stored.get("stations")
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog";
|
||||
import {
|
||||
buildCurves,
|
||||
evaluateAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentNode,
|
||||
} from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
@@ -339,25 +345,90 @@ export function oneStationList<S extends ListedStation, P extends { chainage_m:
|
||||
}
|
||||
|
||||
/**
|
||||
* 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지운다 — 서버
|
||||
* `_follow_moved_stations`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 · 옛 소수 측점의
|
||||
* 정수 m 맞춤 자리(272)면 높이 그대로 옮기고, 나머지는 지움(측점 없는 꼭짓점이 계획선을 꺾지 않게).
|
||||
* 사라진 구조물 측점(`gone`)의 계획선 꼭짓점을 새 측점(`born`)으로 옮기거나 지우고 새로 세운다 — 서버
|
||||
* `_follow_moved_stations` · `_seat_born_nodes`(B05_Profile_Router_Confirm.py) 짝: 0.006m 안 반올림 차이 ·
|
||||
* 옛 소수 측점의 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과
|
||||
* 새 측점이 같은 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 ·
|
||||
* 나머지 새 측점은 자동 계획선 규칙(원지반 지남 · 다른 꼭짓점 그대로)으로 세움 · 짝 없는 옛 꼭짓점은 지움.
|
||||
* 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
|
||||
*/
|
||||
export function followMovedNodes<N extends { chainage_m: number }>(
|
||||
nodes: ReadonlyArray<N>,
|
||||
export function followMovedNodes(
|
||||
base: Pick<AlignmentBase, "basePvi" | "chainage" | "ground" | "policy">,
|
||||
gone: ReadonlyArray<number>,
|
||||
born: ReadonlyArray<number>,
|
||||
): N[] {
|
||||
edited: ReadonlyArray<string> = [],
|
||||
): AlignmentNode[] {
|
||||
const target = (old: number): number | null => {
|
||||
const near = born.find((value) => Math.abs(value - old) < 0.006);
|
||||
if (near !== undefined) return near;
|
||||
const snapped = Math.floor(old + 0.5);
|
||||
return born.some((value) => Math.abs(value - snapped) < EPS) ? snapped : null;
|
||||
};
|
||||
return nodes.flatMap((node) => {
|
||||
const moves = new Map(gone.map((old) => [old, target(old)]));
|
||||
const taken = [...moves.values()].filter((value): value is number => value !== null);
|
||||
const left = [...born]
|
||||
.filter((value) => taken.every((key) => Math.abs(key - value) >= EPS))
|
||||
.sort((a, b) => a - b);
|
||||
const lost = gone.filter((old) => moves.get(old) === null).sort((a, b) => a - b);
|
||||
// 꼭짓점을 새로 세울 측점 — 짝이면 높이 안 고친 것만 · 짝 없으면 새 측점 전부
|
||||
let seat = left;
|
||||
if (lost.length && lost.length === left.length) {
|
||||
seat = [];
|
||||
lost.forEach((old, index) => {
|
||||
moves.set(old, left[index]);
|
||||
if (!edited.some((key) => Math.abs(Number(key) - old) < 0.006)) seat.push(left[index]);
|
||||
});
|
||||
}
|
||||
const kept = base.basePvi.flatMap((node) => {
|
||||
const old = gone.find((value) => Math.abs(value - node.chainage_m) < 0.006);
|
||||
if (old === undefined) return [node];
|
||||
const to = target(old);
|
||||
return to === null ? [] : [{ ...node, chainage_m: to }];
|
||||
const to = moves.get(old) ?? null;
|
||||
return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }];
|
||||
});
|
||||
return seatBornNodes(base, kept, seat);
|
||||
}
|
||||
|
||||
/** 새 측점에 원지반을 지나는 꼭짓점(서버 `_seat_born_nodes` 짝 · `pass_through` 규칙 · 다른 꼭짓점 그대로). */
|
||||
function seatBornNodes(
|
||||
base: Pick<AlignmentBase, "chainage" | "ground" | "policy">,
|
||||
nodes: AlignmentNode[],
|
||||
born: ReadonlyArray<number>,
|
||||
): AlignmentNode[] {
|
||||
if (!nodes.length || base.chainage.length < 2) return nodes;
|
||||
const first = nodes[0].chainage_m;
|
||||
const last = nodes[nodes.length - 1].chainage_m;
|
||||
const fresh = born.filter(
|
||||
(value) =>
|
||||
first < value &&
|
||||
value < last &&
|
||||
nodes.every((node) => Math.abs(value - node.chainage_m) >= 0.006),
|
||||
);
|
||||
if (!fresh.length) return nodes;
|
||||
const rows = [
|
||||
...nodes.map((node) => ({
|
||||
s: node.chainage_m,
|
||||
z: node.elevation_m,
|
||||
node: node as AlignmentNode | null,
|
||||
})),
|
||||
...fresh.map((s) => ({ s, z: evaluateAt(base.chainage, base.ground, [], s), node: null })),
|
||||
].sort((a, b) => a.s - b.s);
|
||||
const baseS = rows.map((row) => row.s);
|
||||
const target = rows.map((row) => row.z);
|
||||
let baseZ = [...target];
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
const curves = buildCurves(pvi, base.policy, {}, []);
|
||||
const error = rows.map((row, index) =>
|
||||
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
|
||||
);
|
||||
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
|
||||
baseZ = baseZ.map((z, index) => z + error[index]);
|
||||
}
|
||||
return rows.map(
|
||||
(row, index) =>
|
||||
row.node ?? {
|
||||
chainage_m: Number(row.s.toFixed(6)),
|
||||
elevation_m: Number(baseZ[index].toFixed(6)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ function loadTs(file) {
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
// 427 — 꼭짓점 세움이 종단 선형(B05 logic)을 부름 · 상대 경로 TS 는 그대로 읽음
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request} (${file})`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
@@ -26,7 +28,7 @@ const station = loadTs("common_util/common_util_structure_station.ts");
|
||||
const out = {
|
||||
planted: station.plantedChainages(input.pipes, input.structures, input.types),
|
||||
list: station.oneStationList(input.server, input.fresh),
|
||||
nodes: station.followMovedNodes(input.nodes, input.gone, input.born),
|
||||
nodes: station.followMovedNodes(input.base, input.gone, input.born, input.edited),
|
||||
};
|
||||
// 368-2 추가 측점 — 화면이 둔 읽는 곳(setPlusStations)으로 심는 자리에 듦
|
||||
station.setPlusStations(() => input.plus);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* 427-1 관 · 구조물 옮김 때 꼭짓점 따라감 — 진짜 `common_util_structure_station.ts` 의 `followMovedNodes` 를
|
||||
* Node 로 돌림. 입력 argv[2] JSON [{ base, gone, born, edited }, …] · 표준출력 = 꼭짓점 목록 한 벌씩.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function loadTs(file) {
|
||||
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request} (${file})`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const station = loadTs("common_util/common_util_structure_station.ts");
|
||||
const out = cases.map((item) =>
|
||||
station.followMovedNodes(item.base, item.gone, item.born, item.edited),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -127,7 +127,16 @@ def ts(tmp_path_factory) -> dict:
|
||||
"types": _types(),
|
||||
"server": SERVER,
|
||||
"fresh": FRESH,
|
||||
"nodes": fixture["profile_alignment"]["base_pvi"],
|
||||
"base": {
|
||||
"basePvi": fixture["profile_alignment"]["base_pvi"],
|
||||
# 고정 사본엔 선형 표본이 없음 — 서버도 꼭짓점을 새로 안 세움(427)
|
||||
"chainage": [s["chainage_m"] for s in fixture["profile_alignment"].get("samples", [])],
|
||||
"ground": [
|
||||
s["ground_elevation_m"] for s in fixture["profile_alignment"].get("samples", [])
|
||||
],
|
||||
"policy": fixture["profile_alignment"]["policy"],
|
||||
},
|
||||
"edited": list(fixture["profile_alignment"]["edits"]["station_offsets"]),
|
||||
"gone": [s["chainage_m"] for s in fixture["stations"] if s.get("kind") == "irregular"],
|
||||
"born": [s["chainage_m"] for s in _snapped(fixture)],
|
||||
"plus": PLUS,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""427-1 관 · 구조물 옮김 때 종단 꼭짓점 따라감(요구 255-2 · 분석 424 2장 · 10장).
|
||||
|
||||
까닭: 자동 계획선은 모든 측점이 꼭짓점(원지반 지남) · 관을 옮기면 옛 측점 꼭짓점만 지우고
|
||||
새 측점엔 안 세움 → 351 → 348 → 351 왕복 뒤 351 m 가 원지반보다 깎인 채 굳음.
|
||||
고침: 새 측점에 자동 계획선 규칙으로 꼭짓점을 세움 · 높이 고친 꼭짓점은 높이째 옮김
|
||||
→ 되돌리면 처음 값. 서버 `_follow_moved_stations` · `_seat_born_nodes` ↔ TS `followMovedNodes`
|
||||
(helper_427_1_follow_seat.cjs 가 진짜 TS 를 Node 로 돌림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_curves,
|
||||
evaluate,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
rebuild_alignment_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _merge_irregular_into_longitudinal # noqa: E402
|
||||
|
||||
REL = "B06_Section/longitudinal/longitudinal.json"
|
||||
PIPE = 351.0
|
||||
MOVED = 348.0
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.08 * s + 1.5 * math.sin(s / 7.0)
|
||||
|
||||
|
||||
def _longitudinal(offsets: dict[str, float] | None = None) -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
stations.append({"chainage_m": PIPE, "kind": "irregular", "structure": "파형강관 D800"})
|
||||
stations.sort(key=lambda row: row["chainage_m"])
|
||||
data = {"samples": samples, "stations": stations}
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=100.0,
|
||||
min_curve_length_m=20.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=20,
|
||||
)
|
||||
alignment, profile = design_ground_following_profile(data, options, station_interval_m=20.0)
|
||||
data["profile_alignment"] = alignment
|
||||
data["design_profiles"] = [profile]
|
||||
if offsets:
|
||||
alignment, profile = rebuild_alignment_profile(data, {"station_offsets": offsets})
|
||||
data["profile_alignment"] = alignment
|
||||
data["design_profiles"] = [profile]
|
||||
return data
|
||||
|
||||
|
||||
def _write(tmp_path: Path, data: dict) -> None:
|
||||
path = tmp_path / REL
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
def _move(tmp_path: Path, *to: float) -> dict:
|
||||
marks = [{"chainage_m": at, "kind": "irregular", "structure": "파형강관 D800"} for at in to]
|
||||
_merge_irregular_into_longitudinal(tmp_path, REL, marks)
|
||||
return json.loads((tmp_path / REL).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _rows(data: dict, low: float = 340, high: float = 360) -> dict[float, float]:
|
||||
"""측점 계획고(표 한 줄) — 기본 340~360 m."""
|
||||
return {
|
||||
row["chainage_m"]: row["plan_elevation_m"]
|
||||
for row in data["profile_alignment"]["stations"]
|
||||
if low <= row["chainage_m"] <= high
|
||||
}
|
||||
|
||||
|
||||
def test_옛_판은_옮긴_자리에_꼭짓점이_없어_깎임() -> None:
|
||||
"""까닭 근거 — 꼭짓점을 지우기만 하면 351 m 계획선이 원지반을 안 지남."""
|
||||
data = _longitudinal()
|
||||
base = [n for n in data["profile_alignment"]["base_pvi"] if n["chainage_m"] != PIPE]
|
||||
s = np.array([n["chainage_m"] for n in base])
|
||||
z = np.array([n["elevation_m"] for n in base])
|
||||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(data["profile_alignment"]["policy"]))
|
||||
assert abs(float(evaluate(s, z, curves, np.array([PIPE]))[0]) - _ground(PIPE)) > 0.2
|
||||
|
||||
|
||||
def test_옮긴_새_측점에_원지반_지나는_꼭짓점(tmp_path: Path) -> None:
|
||||
_write(tmp_path, _longitudinal())
|
||||
after = _move(tmp_path, MOVED)
|
||||
base = [n["chainage_m"] for n in after["profile_alignment"]["base_pvi"]]
|
||||
assert MOVED in base and PIPE not in base
|
||||
rows = _rows(after)
|
||||
assert MOVED in rows and PIPE not in rows
|
||||
assert rows[MOVED] == pytest.approx(_ground(MOVED), abs=1e-4)
|
||||
|
||||
|
||||
def test_왕복하면_처음_계획고(tmp_path: Path) -> None:
|
||||
first = _longitudinal()
|
||||
_write(tmp_path, first)
|
||||
_move(tmp_path, MOVED)
|
||||
back = _move(tmp_path, PIPE)
|
||||
before, after = _rows(first), _rows(back)
|
||||
assert list(after) == list(before)
|
||||
for at, value in before.items():
|
||||
assert after[at] == pytest.approx(value, abs=5e-4), at
|
||||
assert after[PIPE] == pytest.approx(_ground(PIPE), abs=1e-4)
|
||||
assert _rows(back, 0, 400).keys() == _rows(first, 0, 400).keys()
|
||||
|
||||
|
||||
def test_높이_고친_꼭짓점은_높이째_옮김(tmp_path: Path) -> None:
|
||||
first = _longitudinal({"351.000": 0.4})
|
||||
_write(tmp_path, first)
|
||||
moved = _move(tmp_path, MOVED)
|
||||
assert moved["profile_alignment"]["edits"]["station_offsets"] == {"348.000": 0.4}
|
||||
|
||||
# 꼭짓점 높이(자동 높이 + 편집) 그대로 — 측점 계획고는 이웃 곡선 따라 조금 바뀜
|
||||
def vertex(data: dict, at: float) -> float:
|
||||
return next(
|
||||
p["elevation_m"] for p in data["profile_alignment"]["pvi"] if p["chainage_m"] == at
|
||||
)
|
||||
|
||||
assert vertex(moved, MOVED) == pytest.approx(vertex(first, PIPE), abs=1e-6)
|
||||
back = _move(tmp_path, PIPE)
|
||||
assert back["profile_alignment"]["edits"]["station_offsets"] == {"351.000": 0.4}
|
||||
before, after = _rows(first), _rows(back)
|
||||
for at, value in before.items():
|
||||
assert after[at] == pytest.approx(value, abs=1e-6), at
|
||||
|
||||
|
||||
def test_짝_없는_새_측점도_세우고_옛_꼭짓점은_지움(tmp_path: Path) -> None:
|
||||
"""관 하나 지우고 둘 놓음(수가 다름) — 새 측점 둘 다 원지반 지남 · 옛 편집 지움."""
|
||||
_write(tmp_path, _longitudinal({"351.000": 0.4}))
|
||||
after = _move(tmp_path, 125.0, 287.0)
|
||||
rows = _rows(after, 0, 400)
|
||||
assert PIPE not in rows
|
||||
assert after["profile_alignment"]["edits"]["station_offsets"] == {}
|
||||
for at in (125.0, 287.0):
|
||||
assert rows[at] == pytest.approx(_ground(at), abs=1e-4)
|
||||
|
||||
|
||||
def _ts(tmp_path: Path, cases: list[dict]) -> list[list[dict]]:
|
||||
source = tmp_path / "in427.json"
|
||||
source.write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_427_1_follow_seat.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offsets", "to"),
|
||||
[(None, [MOVED]), ({"351.000": 0.4}, [MOVED]), (None, [125.0, 287.0])],
|
||||
)
|
||||
def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None:
|
||||
first = _longitudinal(offsets)
|
||||
stored = copy.deepcopy(first["profile_alignment"])
|
||||
_write(tmp_path, first)
|
||||
server = _move(tmp_path, *to)["profile_alignment"]
|
||||
base = {
|
||||
"basePvi": stored["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in stored["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
|
||||
"policy": stored["policy"],
|
||||
}
|
||||
[nodes] = _ts(
|
||||
tmp_path,
|
||||
[{"base": base, "gone": [PIPE], "born": to, "edited": list(offsets or {})}],
|
||||
)
|
||||
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
|
||||
for mine, theirs in zip(nodes, server["base_pvi"]):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
Reference in New Issue
Block a user