fix(b06): 물넘이 연동 범위 안 측점에 자기 관도 함께 얹음 — 키별 하나씩 · 같은 키는 소유 먼저 · 흙막이는 자기 관에 밀림 (299-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -662,24 +662,32 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
||||
chainage = _number(section.get("chainage_m"), None)
|
||||
if chainage is None:
|
||||
continue
|
||||
for pipe_chainage, spec in sets.items():
|
||||
# ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1) — 첫 시설 하나에서 멈추면 물넘이포장
|
||||
# 연동 범위(폭 절반) 안 측점의 **자기 관**이 빠졌다(95 m 배관이 90 m 물넘이에 가려짐).
|
||||
# 같은 키끼리는 소유 측점 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례.
|
||||
best: dict[str, tuple[tuple[Any, ...], dict[str, Any]]] = {}
|
||||
for order, (pipe_chainage, spec) in enumerate(sets.items()):
|
||||
# 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다(`_SPAN_LINKED_TYPES`).
|
||||
reach = _CHAINAGE_TOLERANCE_M
|
||||
if spec.get("type") in _SPAN_LINKED_TYPES:
|
||||
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
|
||||
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
|
||||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||
kind = spec.get("type")
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||||
section[_SECTION_KEYS.get(str(kind), "culvert")] = {
|
||||
**spec,
|
||||
"chainage_m": pipe_chainage,
|
||||
}
|
||||
# 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음.
|
||||
if spec.get("guard"):
|
||||
section["culvert"] = {**spec["guard"], "chainage_m": pipe_chainage}
|
||||
attached += 1
|
||||
break
|
||||
owned = owner.get(pipe_chainage) == chainage
|
||||
if not (abs(chainage - pipe_chainage) <= reach or owned):
|
||||
continue
|
||||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||
entries = [(_SECTION_KEYS.get(str(spec.get("type")), "culvert"), spec, False)]
|
||||
# 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음 ·
|
||||
# 자기 관과 부딪히면 관 먼저(흙막이가 덮으면 관 그림 · 연장이 빠짐).
|
||||
if spec.get("guard"):
|
||||
entries.append(("culvert", spec["guard"], True))
|
||||
for key, value, is_guard in entries:
|
||||
rank = (not owned, is_guard, abs(chainage - pipe_chainage), order)
|
||||
if key not in best or rank < best[key][0]:
|
||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||
# 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려
|
||||
# **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리).
|
||||
best[key] = (rank, {**value, "chainage_m": pipe_chainage})
|
||||
for key, (_, value) in best.items():
|
||||
section[key] = value
|
||||
attached += bool(best)
|
||||
return attached
|
||||
|
||||
@@ -580,6 +580,12 @@ export function buildCulvertSets(
|
||||
return sets;
|
||||
}
|
||||
|
||||
/** 차례 비교(파이썬 튜플 `<` 와 같음) — 처음 다른 자리에서 작은 쪽이 먼저. */
|
||||
function rankBefore(a: readonly number[], b: readonly number[]): boolean {
|
||||
const at = a.findIndex((value, index) => value !== b[index]);
|
||||
return at >= 0 && a[at] < b[at];
|
||||
}
|
||||
|
||||
/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */
|
||||
export function attachCulvertSets(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
@@ -607,25 +613,35 @@ export function attachCulvertSets(
|
||||
for (const section of sections) {
|
||||
const chainage = num(section.chainage_m, null);
|
||||
if (chainage === null) continue;
|
||||
// ⚠ 키가 다른 시설은 같은 측점에 함께 얹는다(299-1 · 짝: 파이썬 `attach_culvert_sets`) — 첫 시설
|
||||
// 하나에서 멈추면 물넘이포장 연동 범위 안 측점의 **자기 관**이 빠졌다. 같은 키끼리는 소유 측점
|
||||
// 것 → 흙막이 아닌 자기 시설 → 가까운 것 → 관 지점 차례.
|
||||
const best = new Map<string, { rank: number[]; value: CulvertSetSpec }>();
|
||||
let order = 0;
|
||||
for (const [pipeChainage, spec] of sets) {
|
||||
order += 1;
|
||||
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
|
||||
let reach = CHAINAGE_TOLERANCE_M;
|
||||
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
|
||||
if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) {
|
||||
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
|
||||
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
|
||||
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
|
||||
section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = {
|
||||
...spec,
|
||||
chainage_m: pipeChainage,
|
||||
};
|
||||
// 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`)에 함께 얹음.
|
||||
if (spec.guard)
|
||||
section.culvert = { ...(spec.guard as CulvertSetSpec), chainage_m: pipeChainage };
|
||||
attached += 1;
|
||||
break;
|
||||
const owned = owner.get(pipeChainage) === chainage;
|
||||
if (!(Math.abs(chainage - pipeChainage) <= reach || owned)) continue;
|
||||
const entries: Array<[string, CulvertSetSpec, number]> = [
|
||||
[SECTION_KEYS[String(spec.type)] ?? "culvert", spec, 0],
|
||||
];
|
||||
// 세월교 · 박스 흙막이(73-4) — 배관 흙막이와 같은 자리(`culvert`) · 자기 관과 부딪히면 관 먼저.
|
||||
if (spec.guard) entries.push(["culvert", spec.guard as CulvertSetSpec, 1]);
|
||||
for (const [key, value, isGuard] of entries) {
|
||||
const rank = [owned ? 0 : 1, isGuard, Math.abs(chainage - pipeChainage), order];
|
||||
const held = best.get(key)?.rank;
|
||||
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 절반까지
|
||||
// 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러
|
||||
// 측점에서 센다**.
|
||||
if (!held || rankBefore(rank, held))
|
||||
best.set(key, { rank, value: { ...value, chainage_m: pipeChainage } });
|
||||
}
|
||||
}
|
||||
for (const [key, { value }] of best) section[key] = value;
|
||||
if (best.size) attached += 1;
|
||||
}
|
||||
return attached;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""299-1 물넘이 연동 범위 안 측점의 자기 관 — 같은 측점에 함께 얹힘 · 그림 · 수량 한 번.
|
||||
|
||||
까닭 — `attach_culvert_sets` 가 측점마다 첫 맞는 시설 하나만 얹고 멈춰, 검증 프로젝트 95 m 배관
|
||||
(관경 800 · 도수로)이 90 m 물넘이포장(폭 10 → 85~95 m 연동)에 가려 횡단도 · 관 연장에서 빠졌다.
|
||||
|
||||
못박는 것
|
||||
- 키가 다른 시설(배관 culvert · 세월교 ford · 박스 box · 물넘이 ford_pavement)은 같은 측점에 함께.
|
||||
- 같은 키는 소유 측점 것이 옆 측점 연동보다 먼저 · 세월교 · 박스 흙막이(guard)는 자기 관과
|
||||
부딪히면 관 먼저(흙막이 없는 자리는 그대로 흙막이).
|
||||
- 수량 = 소유 측점 한 번 — 소비처(B06 `Structure_Layouts` · B08)가 `chainage_m` 의 가장 가까운
|
||||
측점만 세므로 시설마다 주인 한 곳 · 관 연장은 주인 측점의 `culvert`(흙막이 `body` 아님)만.
|
||||
- 파이썬 ↔ TS 짝(`common_util_culvert_sets.ts`) 같은 값.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
for path in (ROOT, Path(__file__).resolve().parent):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
import test_b06_culvert_sets_mirror as mirror # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file # noqa: E402
|
||||
|
||||
# 검증 프로젝트 꼴 — 20 · 90 물넘이(폭 10) · 95 배관(도수로) · 100 배관 + 100.3 박스(유출 흙막이) · 60 세월교(흙막이)
|
||||
POINTS = [
|
||||
{"chainage_m": 20.0, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
|
||||
{"chainage_m": 90.0, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
|
||||
{
|
||||
"chainage_m": 95.0,
|
||||
"facility": "pipe",
|
||||
"options": {"pipe_diameter_mm": 800, "chute_add_1_structure": "도수로"},
|
||||
},
|
||||
{
|
||||
"chainage_m": 60.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {
|
||||
"ford_width_m": 6.0,
|
||||
"inlet_type": "흙막이",
|
||||
"inlet_revet_form": "콘크리트",
|
||||
"inlet_revet_height_m": 1.5,
|
||||
},
|
||||
},
|
||||
{"chainage_m": 100.0, "facility": "pipe", "options": {"pipe_diameter_mm": 600}},
|
||||
{
|
||||
"chainage_m": 100.3,
|
||||
"facility": "box_culvert",
|
||||
"options": {"outlet_revet_form": "돌쌓기(찰)", "outlet_revet_height_m": 3.5},
|
||||
},
|
||||
]
|
||||
CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 85.0, 90.0, 95.0, 100.0, 105.0]
|
||||
|
||||
# 같은 키 — 옆 측점 연동(88.5 · 폭 10 · 주인 88)이 90 m 에 더 가깝지만 소유(92.4 · 주인 90)가 먼저
|
||||
SAME_KEY_POINTS = [
|
||||
{"chainage_m": 88.5, "facility": "ford_pavement", "options": {"ford_width_m": 10.0}},
|
||||
{"chainage_m": 92.4, "facility": "ford_pavement", "options": {"ford_width_m": 0.1}},
|
||||
]
|
||||
SAME_KEY_CHAINAGES = [85.0, 88.0, 90.0, 95.0]
|
||||
|
||||
|
||||
def _attach(tmp_path: Path, points: list[dict], chainages: list[float]) -> list[dict]:
|
||||
target = pipe_points_file(tmp_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8")
|
||||
sections = [{"chainage_m": value} for value in chainages]
|
||||
attach_culvert_sets(tmp_path, sections)
|
||||
return sections
|
||||
|
||||
|
||||
def _at(sections: list[dict], chainage: float) -> dict:
|
||||
return next(s for s in sections if s["chainage_m"] == chainage)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sections(tmp_path: Path) -> list[dict]:
|
||||
return _attach(tmp_path, POINTS, CHAINAGES)
|
||||
|
||||
|
||||
def test_연동_물넘이_범위_안_자기_관_함께(sections):
|
||||
at95 = _at(sections, 95.0)
|
||||
assert at95["culvert"]["chainage_m"] == 95.0
|
||||
assert at95["culvert"]["type"] == "pipe"
|
||||
assert at95["culvert"]["chute"]["structure"] == "도수로"
|
||||
assert at95["ford_pavement"]["chainage_m"] == 90.0
|
||||
assert set(_at(sections, 85.0)) == {"chainage_m", "ford_pavement"}
|
||||
|
||||
|
||||
def test_흙막이는_자기_관에_밀림_없으면_그대로(sections):
|
||||
at100 = _at(sections, 100.0)
|
||||
assert at100["box"]["chainage_m"] == 100.3
|
||||
assert at100["culvert"]["chainage_m"] == 100.0
|
||||
assert "body" not in at100["culvert"] # 관 — 흙막이 세트 아님
|
||||
at60 = _at(sections, 60.0)
|
||||
assert at60["ford"]["chainage_m"] == 60.0
|
||||
assert at60["culvert"]["chainage_m"] == 60.0 # 세월교 흙막이
|
||||
|
||||
|
||||
def test_같은_키는_소유_측점_먼저(tmp_path):
|
||||
sections = _attach(tmp_path, SAME_KEY_POINTS, SAME_KEY_CHAINAGES)
|
||||
assert _at(sections, 90.0)["ford_pavement"]["chainage_m"] == 92.4
|
||||
assert _at(sections, 85.0)["ford_pavement"]["chainage_m"] == 88.5
|
||||
|
||||
|
||||
def test_수량은_주인_측점_한_번(sections):
|
||||
"""소비처 규칙 — 시설 `chainage_m` 의 가장 가까운 측점만 셈."""
|
||||
owner = {
|
||||
p["chainage_m"]: min(CHAINAGES, key=lambda c: abs(c - p["chainage_m"])) for p in POINTS
|
||||
}
|
||||
counted: dict[tuple[str, float], int] = {}
|
||||
for section in sections:
|
||||
for key in ("culvert", "ford", "box", "ford_pavement"):
|
||||
value = section.get(key)
|
||||
if value and owner[value["chainage_m"]] == section["chainage_m"]:
|
||||
name = "guard" if key == "culvert" and "body" in value else key
|
||||
counted[(name, value["chainage_m"])] = (
|
||||
counted.get((name, value["chainage_m"]), 0) + 1
|
||||
)
|
||||
assert counted == {
|
||||
("ford_pavement", 20.0): 1,
|
||||
("ford_pavement", 90.0): 1,
|
||||
("culvert", 95.0): 1, # 관 연장 · 도수로 — 95 m 한 번
|
||||
("ford", 60.0): 1,
|
||||
("guard", 60.0): 1,
|
||||
("culvert", 100.0): 1,
|
||||
("box", 100.3): 1,
|
||||
}
|
||||
# 박스 흙막이는 자기 관에 밀려 어디에도 안 실림 — 관 연장 · 수량을 두 번 세지 않음
|
||||
assert not any(s.get("culvert", {}).get("chainage_m") == 100.3 for s in sections)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not mirror.TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
@pytest.mark.parametrize(
|
||||
("points", "chainages"), [(POINTS, CHAINAGES), (SAME_KEY_POINTS, SAME_KEY_CHAINAGES)]
|
||||
)
|
||||
def test_파이썬_TS_짝_같은_값(tmp_path, monkeypatch, points, chainages):
|
||||
expected = _attach(tmp_path / "py", points, chainages)
|
||||
monkeypatch.setattr(mirror, "POINTS", points)
|
||||
monkeypatch.setattr(mirror, "CHAINAGES", chainages)
|
||||
assert mirror._ts_sections(tmp_path) == expected
|
||||
Reference in New Issue
Block a user