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:
2026-10-12 22:18:40 +09:00
co-authored by Claude Opus 5.5
parent be5ef91f72
commit e2e71ad3ca
3 changed files with 198 additions and 29 deletions
+24 -16
View File
@@ -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
+29 -13
View File
@@ -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;
}
+145
View File
@@ -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