Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-29 09:40:11 +09:00
9 changed files with 125 additions and 53 deletions
+23 -12
View File
@@ -1,4 +1,5 @@
"""노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
"""노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리는 옛 종단 길이 비례
(344-2 · 400 · 575 B).
사용자 확정(10-16 · 341 결정 1): 노선 끝쪽을 고쳤는데 앞쪽 계획고가 날아가지 않게 한다.
요구 250-2둘째(400): 평면을 고쳐도 크게 안 바뀐 자리의 종단 편집(직선화 · 자유지정 · 쉬프트 ·
@@ -9,7 +10,8 @@
- 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
(측점 스냅이 뒤 교점을 밀어도 이어받음).
- 자동 꼭짓점(`carry_base`) · 편집(Node `carry_seat`)은 **새 측점 위에** 다시 놓음 — 같은 자리면
옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 ·
옛 선 그 자리 높이 · 아니면 앞뒤 같은 자리 사이 길이 비례 자리 옛 높이 · 편집은 옛 사용자
꼭짓점이 닿은 자리만 ·
R 은 가까운 측점. 편집 셈 = TS `carryEdits`(서버 Node · 511-B B-3) — 파이썬 짝 없음.
짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 =
@@ -124,21 +126,30 @@ def carry_base(
ground_pvi: list[dict[str, float]],
tol_m: float = SAME_LINE_TOL_M,
) -> list[dict[str, float]]:
"""새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반."""
"""새 자동 꼭짓점 — 지반 첫 계획선 측점 자리마다 옛 자동선 높이(575 B).
같은 자리 = 맞춘 옛 누가거리 · 바뀐 자리 = 앞뒤 같은 자리(없으면 노선 시점 · 종점) 사이를 새
누가거리 비례로 옛 누가거리에 옮김(옛 종단 모양을 늘이고 줄임 · 다시 풀지 않음).
"""
if not old_base or not ground_pvi:
return [dict(node) for node in old_base]
match = line_matcher(old_xy, new_xy, tol_m)
base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64)
base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64)
chainages = [float(node["chainage_m"]) for node in ground_pvi]
hits = [match(t) for t in chainages]
# 비례 기준점 — 시점(0 · 0) · 같은 자리 · 종점(새 · 옛 연장) 중 두 누가거리 모두 늘어나는 것만
# ponytail: 앞에서부터 고름 — 같은 방향 먼 옛 토막에 잘못 맞은 점이 있으면 뒤 기준점을 버림
at, to = [0.0], [0.0]
ends = (_cumulative(list(new_xy))[-1], _cumulative(list(old_xy))[-1])
for t, s in [*zip(chainages, hits), ends]:
if s is not None and t > at[-1] and s > to[-1]:
at.append(t)
to.append(s)
out = []
for node in ground_pvi:
s = match(float(node["chainage_m"]))
z = (
float(node["elevation_m"])
if s is None
else round(float(np.interp(s, base_s, base_z)), 6)
)
out.append({"chainage_m": float(node["chainage_m"]), "elevation_m": z})
for t, s in zip(chainages, hits):
s = float(np.interp(t, at, to)) if s is None else s
out.append({"chainage_m": t, "elevation_m": round(float(np.interp(s, base_s, base_z)), 6)})
return out
@@ -149,7 +160,7 @@ def carry_policy(
new_alignment: dict[str, Any],
) -> dict[str, Any]:
"""노선 [저장] 정책(432) — 새 정책에 옛 기본 L · 옛 정책 없으면 새 정책. 바뀐 자리 새 꼭짓점은
새 지반 첫 계획선(직선화 438 · `carry_base`)."""
새 지반 첫 계획선 측점 자리에 옛 높이 비례(`carry_base` · 575 B)."""
new_policy = dict(new_alignment.get("policy") or {})
old_policy = old_alignment.get("policy") or {}
if not old_policy:
+2 -2
View File
@@ -24,7 +24,7 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan
- 손 고침 [저장](교점 · R · 스냅 · 간격 · 요구 289 · 290) = 보이는 화면 그대로 — 관 정본을
지우지 않음 · 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
- 계획고 = 바뀐 자리는 옛 종단 길이 비례(575 B) · 같은 자리(옆 1 m 안) 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) ·
추천 [적용](`as_initial`) = 옮기지 않음 — 균형 종단(`apply_profile_target`) · 1차 계획선(438) ·
횡단 손 입력도 안 넘김(요구 277 · 측점마다 기본 설계).
@@ -254,7 +254,7 @@ async def _carry_profile(
old_xy: list[list[float]],
new_xy: list[list[float]],
) -> None:
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400)."""
"""같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리는 옛 종단 길이 비례(400 · 575 B)."""
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_policy, changed_span
from B05_Profile.B05_Profile_Engine_Grade_Node import carry_seat
+4 -1
View File
@@ -588,7 +588,10 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
choice.mode,
choice.weights.model_dump() if choice.weights else None,
(choice.criteria.model_dump(exclude_none=True) or None) if choice.criteria else None,
request.balanced_profile, # 「종단 반영」 — 다시 열면 되살림(420)
# 「종단 반영」 — 다시 열면 되살림(420) · 손 [저장]은 적힌 값 그대로(575)
request.balanced_profile
if as_initial
else read_initial_choice(project_root)["with_profile"],
)
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
+25 -9
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Grade_Carry.ts
* 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
* 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리는 옛 종단을 길이 비례로(344-2 · 400 · 575 B).
*
* 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 =
* resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py.
@@ -8,7 +8,7 @@
* - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
* (측점 스냅이 뒤 교점을 밀어도 이어받음).
* - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리
* 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점.
* 높이 · 아니면 앞뒤 같은 자리 사이 길이 비례 자리 옛 높이 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점.
* ========================================================================== */
import {
@@ -159,7 +159,11 @@ export function lineMatcher(
};
}
/** 새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반(파이썬 `carry_base` 짝). */
/**
* 새 자동 꼭짓점 — 지반 첫 계획선 측점 자리마다 옛 자동선 높이(575 B · 파이썬 `carry_base` 짝).
* 같은 자리 = 맞춘 옛 누가거리 · 바뀐 자리 = 앞뒤 같은 자리(없으면 노선 시점 · 종점) 사이를 새 누가거리 비례로
* 옛 누가거리에 옮김(옛 종단 모양을 늘이고 줄임 · 다시 풀지 않음).
*/
export function carryBase(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
@@ -171,13 +175,25 @@ export function carryBase(
const match = lineMatcher(oldXy, newXy, tolM);
const baseS = oldBase.map((node) => node.chainage_m);
const baseZ = oldBase.map((node) => node.elevation_m);
return groundPvi.map((node) => {
const s = match(node.chainage_m);
return {
chainage_m: node.chainage_m,
elevation_m: s === null ? node.elevation_m : roundTo(interp(baseS, baseZ, s), 6),
};
const hits = groundPvi.map((node) => match(node.chainage_m));
// 비례 기준점 — 시점(0 · 0) · 같은 자리 · 종점(새 연장 · 옛 연장) 중 두 누가거리 모두 늘어나는 것만
// ponytail: 앞에서부터 고름 — 같은 방향 먼 옛 토막에 잘못 맞은 점이 있으면 뒤 기준점을 버림
const at = [0];
const to = [0];
const pairs = groundPvi.map((node, index) => ({ t: node.chainage_m, s: hits[index] }));
const newCum = cumulative(newXy);
const oldCum = cumulative(oldXy);
pairs.push({ t: newCum[newCum.length - 1], s: oldCum[oldCum.length - 1] });
pairs.forEach(({ t, s }) => {
if (s !== null && t > at[at.length - 1] && s > to[to.length - 1]) {
at.push(t);
to.push(s);
}
});
return groundPvi.map((node, index) => ({
chainage_m: node.chainage_m,
elevation_m: roundTo(interp(baseS, baseZ, hits[index] ?? interp(at, to, node.chainage_m)), 6),
}));
}
/**
@@ -8,7 +8,8 @@
* 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3) ·
* 노선 바뀐 [저장]의 계획고 손댐(`touch` · 저장분 옮김과 다른 것만 · 498-2).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
* 높이 · 바뀐 자리는 저장 꼭짓점 길이 비례(575 B · 측점 자리 = 새 지반 첫 계획선 `Grade_Ground` ·
* `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
* `route_moved_stations` · 서버 세움 = Node `profile.carry` · 거울 시험 test_459_route_seat_mirror.py).
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
@@ -447,7 +448,8 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
})),
};
};
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438) · 계산 자리
// 바뀐 자리 새 꼭짓점 자리 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438 · 높이는
// `carryBase` 가 옛 종단 비례 · 575 B) · 계산 자리
// 「서버」 면 서버 Node(`groundPvi` · 511-B B-3)
const input = {
samples,
+14 -5
View File
@@ -420,22 +420,31 @@ def test_가운데를_고치면_앞은_그대로_뒤는_같은자리_계획고()
assert "90.500" not in edits["station_offsets"]
assert edits["curve_radii"]["40.000"] == 300.0
moved = _kept_plan(case, base_pvi, edits)
# 바뀐 자리는 새 지반 첫 계획선 꼭짓점 · 편집 없음
# 바뀐 자리는 옛 종단 길이 비례(앞뒤 같은 자리 사이 · 575 B) · 지반 아님 · 편집 없음
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in case["ground_pvi"]}
got = {round(p["chainage_m"], 3): p["elevation_m"] for p in base_pvi}
assert moved and all(got[round(t, 3)] == ground[round(t, 3)] for t in moved)
match = line_matcher(_pts(case, "old_xy"), _pts(case, "new_xy"))
anchors = [(t, match(t)) for t in sorted(got) if match(t) is not None]
old_s = [p["chainage_m"] for p in case["base_pvi"]]
old_z = [p["elevation_m"] for p in case["base_pvi"]]
for t in moved:
t0, s0 = max(a for a in anchors if a[0] < t)
t1, s1 = min(a for a in anchors if a[0] > t)
want = float(np.interp(s0 + (s1 - s0) * (t - t0) / (t1 - t0), old_s, old_z))
assert got[round(t, 3)] == pytest.approx(want, abs=1e-6), t
assert moved and any(got[round(t, 3)] != ground[round(t, 3)] for t in moved)
assert not {f"{t:.3f}" for t in moved} & set(edits["station_offsets"])
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + span["delta_m"], abs=1e-3)
def test_끝을_고치면_끝_꼭짓점은_새_지반():
def test_끝을_고치면_끝_꼭짓점은_옛_끝높이():
case = CARRY_CASES[1]
span, (base_pvi, edits) = _py_carry(case)
assert not span["end_fixed"]
assert f"{L_OLD:.3f}" not in edits["station_offsets"] # 바뀐 끝 편집은 버림
_kept_plan(case, base_pvi, edits) # 앞(90.5 편집 둘레) 계획고 그대로
ground_end = case["ground_pvi"][-1]
assert base_pvi[-1] == {
assert base_pvi[-1] == { # 575 B — 종점 = 옛 종점 높이(새 연장 ↔ 옛 연장 비례)
"chainage_m": ground_end["chainage_m"],
"elevation_m": ground_end["elevation_m"],
"elevation_m": round(case["base_pvi"][-1]["elevation_m"], 6),
}
+11
View File
@@ -508,6 +508,17 @@ def test_균형_종단은_추천_적용만_손_저장은_계획고_옮김(api_wo
assert calls[1][0] is not None # 추천 [적용] = 균형 종단 그대로
def test_손_저장은_종단_반영_적힌_값_그대로(api_world, monkeypatch):
"""575 덧 흠 — 손 [저장] 본문엔 `balanced_profile` 이 없어 「종단 반영」을 끔으로 덮던 것."""
written: list = []
monkeypatch.setattr(replan, "write_initial_choice", lambda *a: written.append(a) or {})
monkeypatch.setattr(replan, "read_initial_choice", lambda root: {"with_profile": True})
initial = {"mode": "earthwork", "with_profile": False}
asyncio.run(replan.replan_route(uuid4(), _body(initial=initial))) # 손 [저장]
asyncio.run(replan.replan_route(uuid4(), _body(initial=initial, as_initial=True))) # [적용] 끔
assert [call[-1] for call in written] == [True, False]
def test_초안_칸이_어긋나면_400(api_world):
body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]})
response = asyncio.run(replan.replan_route(uuid4(), body))
+42 -13
View File
@@ -128,6 +128,24 @@ def _plan(base: list[dict], edits: dict) -> tuple[np.ndarray, np.ndarray]:
return pvi_s, pvi_z
def _stretched(old_xy, new_xy, base, chainages) -> dict[float, float]:
"""575 B 기대값 — 바뀐 측점 = 앞뒤 같은 자리(없으면 시점 · 종점) 사이 누가거리 비례 옛 높이."""
match = line_matcher(old_xy, new_xy)
hits = {t: match(t) for t in chainages}
anchors = [(0.0, 0.0)] + [(t, s) for t, s in hits.items() if s is not None]
anchors.append((_length(new_xy), _length(old_xy)))
base_s = [n["chainage_m"] for n in base]
base_z = [n["elevation_m"] for n in base]
out = {}
for t, s in hits.items():
if s is None:
t0, s0 = max((a for a in anchors if a[0] < t), default=anchors[0])
t1, s1 = min((a for a in anchors if a[0] > t), default=anchors[-1])
s = s0 + (s1 - s0) * (t - t0) / (t1 - t0) if t1 > t0 and s1 > s0 else s0
out[t] = round(float(np.interp(s, base_s, base_z)), 6)
return out
def _py(name: str) -> tuple[list[dict], dict]:
new = CASES[name]
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
@@ -262,17 +280,19 @@ def test_같은자리_계획고_그대로(name):
assert new_z == pytest.approx(old_z, abs=1e-3), (name, t)
def test_먼교점_옮김_앞쪽_편집_누가거리_그대로_바뀐자리만_지반():
def test_먼교점_옮김_앞쪽_편집_누가거리_그대로_바뀐자리는_옛종단_비례():
kept, moved, base, edits = _same_place_plan("far")
# 앞쪽(같은 선 · 같은 측점) 편집 글자 그대로
for key in ("60.000", "80.000", "100.000", "300.000"):
assert edits["station_offsets"][key] == pytest.approx(OLD_EDITS["station_offsets"][key])
assert edits["curve_radii"]["300.000"] == 400.0
# 바뀐 자리 = 옮긴 교점 둘레 · 그 측점은 지반 첫 계획선 · 편집 없음(옛 520 · 540 자유지정 버림)
# 바뀐 자리 = 옮긴 교점 둘레 · 그 측점은 옛 종단 길이 비례(지반 아님 · 575 B) · 편집 없음
# (옛 520 · 540 자유지정 버림)
assert moved and min(moved) > 423.0
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(CASES["far"])}
by = {n["chainage_m"]: n["elevation_m"] for n in base}
assert all(by[t] == ground[t] for t in moved)
want = _stretched(OLD, CASES["far"], OLD_BASE, list(by))
assert all(by[t] == pytest.approx(want[t], abs=1e-4) and by[t] != ground[t] for t in moved)
assert not any(float(k) in moved for k in edits["station_offsets"])
@@ -286,11 +306,16 @@ def test_시점_늘림_누가거리_투영_새측점_위():
assert edits["curve_radii"].get("320.000") == 400.0
def test_끝_늘림_옛끝까지_이어받고_넘는자리만_지반():
def test_끝_늘림_옛끝까지_이어받고_넘는자리는_옛_끝높이():
kept, moved, base, _ = _same_place_plan("end")
old_len = _length(OLD)
assert moved and min(moved) > old_len
assert max(t for t, _, _ in kept) > old_len - INTERVAL
# 넘는 자리 = 마지막 같은 자리 ~ 옛 끝 비례로 늘림 · 새 끝 = 옛 끝 높이(575 B)
by = {n["chainage_m"]: n["elevation_m"] for n in base}
want = _stretched(OLD, CASES["end"], OLD_BASE, list(by))
assert all(by[t] == pytest.approx(want[t], abs=1e-4) for t in moved)
assert base[-1]["elevation_m"] == pytest.approx(OLD_BASE[-1]["elevation_m"], abs=1e-4)
def test_스냅처럼_옆으로_밀림_1m_안_이어받고_넘으면_다시():
@@ -301,20 +326,24 @@ def test_스냅처럼_옆으로_밀림_1m_안_이어받고_넘으면_다시():
assert "300.000" in edits["station_offsets"]
def test_종단_안_고친_프로젝트는_편집_없음_꼭짓점은_옛것_또는_지반():
def test_종단_안_고친_프로젝트는_편집_없음_꼭짓점은_옛것_또는_옛종단_비례():
empty = {"station_offsets": {}, "curve_radii": {}}
new = CASES["far"]
assert carry_edits(OLD, new, OLD_BASE, empty, _stations(new), HALF) == empty
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
old_s = [n["chainage_m"] for n in OLD_BASE]
old_z = [n["elevation_m"] for n in OLD_BASE]
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(new)}
match = line_matcher(OLD, new)
want = _stretched(OLD, new, OLD_BASE, [n["chainage_m"] for n in base])
for node in base:
t = node["chainage_m"]
s = match(t)
want = ground[t] if s is None else round(float(np.interp(s, old_s, old_z)), 6)
assert node["elevation_m"] == pytest.approx(want, abs=1e-9)
assert node["elevation_m"] == pytest.approx(want[node["chainage_m"]], abs=1e-4)
@pytest.mark.parametrize("name", ["far", "start", "end", "snap"])
def test_바뀐자리_옛종단_비례_575B(name):
new = CASES[name]
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
want = _stretched(OLD, new, OLD_BASE, [n["chainage_m"] for n in base])
for node in base:
# 끝 측점 반올림(mm)으로 기준점 고름이 갈려도 0.1 mm 안
assert node["elevation_m"] == pytest.approx(want[node["chainage_m"]], abs=1e-4), name
def test_엇갈려_지나는_선은_같은자리_아님():
@@ -241,11 +241,6 @@ def test_초안_planline_은_서버와_같은_꼭짓점(tmp_path: Path, balanced
old = {n["chainage_m"]: n["elevation_m"] for n in out["carried"]}
new = {n["chainage_m"]: n["elevation_m"] for n in out["basePvi"]}
assert new[PIPE] == pytest.approx(old[PIPE], abs=1e-9) # 같은 자리 관 = 옛 높이
if not balanced: # 옮긴 시설 측점 계획선 = 새 원지반(옛 계획선이 지반 종단)
for at in (251.0, 333.0):
assert _plan_at(out["basePvi"], alignment["policy"], at) == pytest.approx(
_new_ground(at), abs=1e-4
)
@pytest.mark.parametrize("balanced", [False, True])
@@ -266,10 +261,6 @@ def test_노선_초안_중_마커로_옮긴_관도_서버_차례와_같은_값(t
kept = [n for n in out["home"] if abs(n["chainage_m"] - PIPE) >= 0.006]
server = _seat_born_nodes(_new_alignment(alignment, out["home"]), kept, [MOVED]) # 관 저장 뒤
_same_nodes(out["basePvi"], server, tol=1e-5) # 맞춤 풀이 수렴 폭(0.000001 m) 안팎
if not balanced:
assert _plan_at(out["basePvi"], alignment["policy"], MOVED) == pytest.approx(
_old_ground(MOVED), abs=1e-4
)
def test_구조물_초안은_서버_따라감_위에(tmp_path: Path) -> None: