PLAN 20장 소수 측점 횡단(sub6). 사용자 확정: 관 · 구조물이 놓인 소수 측점(예 119.8)에 그 자리 횡단을
따로 둠 · 백엔드 · 파일 · 저장값은 소수 그대로 · 화면 표기만 「측점 + 거리」의 거리를 정수로(5+20).
- 이름 규칙 한 벌(B05_Profile_Engine_Sections): cross_key · cross_filename · parse_cross_key ·
CROSS_KEY_PATTERN — 정수 m 는 옛 이름 그대로(cross_00120m) · 소수 측점은 cross_00119.80m.
find_cross_file = 읽기용(새 이름이 없으면 옛 정수 m 이름) · prune_stale_cross_files 도 이것으로
가려 옛 프로젝트의 정수 이름 파일을 지우지 않음
- generate_sections: 정수 m 격자 붙임을 풂 — 같은 이름이 되는 0.005m 안 겹침만 하나로
- B06: 찾기 · 패치 입력(Router_Design 둘) · 샘플 넓힘(SampleExtend)이 find_cross_file ·
DB 설계 찾기 · 병합(get_cross_section_design · merge_…_by_round)은 ROUND(chainage_m)=정수 →
0.005m 안 매칭
- B07: 도면 id · 장 id(cross_{key}m · cross_s{key}m) 정규식 · 측점 키(cross_station — 정수는 int
그대로라 옛 id · 수량표 키 안 바뀜) · 측점은 파일 속 값으로(옛 정수 이름 파일도) · 횡단 파일 찾기
- 화면 표기 한 벌 common_util/common_util_station.ts(stationLabel · stationPartsLabel · 순수 함수) —
common_util_svg 가 다시 내보내고 B05 formatStation 을 대신함(B05 목록 · 표시 · 폼 범위 · 계획노선
편집 · 비정규 측점 라벨 · B06 카드 · 종단 · 유토) · 입력 칸 · 저장값은 소수 그대로
- B05_Profile_UI_Structures_Panel 700줄 → 690(순수 도우미 셋을 Panel_Types 로)
- M02_Table_Fill 관연장 찾기는 이미 측점 값(정확 먼저 · 0.5m 안)이라 그대로
시험: test_b05_station_roundtrip(표기 5+20 · 옛 정수 이름 찾기 · 안 지움 · 119.8 과 120 따로) ·
test_b05_extra_stations_supplier(새 규칙) · test_b06_patch_station(119.8 한 건) ·
test_b05_b06_save_roundtrip(보낸 관 측점마다 소수 이름 횡단 파일 있음) 등 고친 대상 108 통과 ·
test_b05_station_merge 통과 · 타입검사 통과.
ORCA(IP 5181 · 사본 프로젝트 sub6_검증 — 끝나고 DB 행 · 폴더 지움 · 원본 안 씀): 노출형 횡단수로를
5+19.8 에 놓고 저장 → structures.json 119.8 · 종단 측점 119.8(irregular) + 120 · 파일
cross_00119.80m.json 과 cross_00120m.json · B05 목록 「5+20」 · B06 카드 「5+20 · 119.8m」 열림.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVM3xZ1aUPcUTuW2WZSnwg
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""구조물(비정규) 측점의 단일 공급자 — 2026-08-28 [초기화] 후 측점 실종 건.
|
|
|
|
배경: 측점 정본은 파일(longitudinal.json stations + cross_*.json)인데, 그 파일을 쓰는
|
|
`run_section_generation`에는 공급자가 없었다. 유일한 공급자가 프론트 [임시저장]이라
|
|
초기화·반폭 재생성이 파일을 다시 쓸 때마다 구조물 측점이 통째로 사라졌다(실측: 초기화
|
|
직후 cross 23 → 19, irregular 4 → 0).
|
|
"""
|
|
|
|
import json
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
|
_load_pipe_points,
|
|
cross_filename,
|
|
resolve_extra_stations,
|
|
run_section_generation,
|
|
)
|
|
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
|
SectionGenerationOptions,
|
|
generate_sections,
|
|
)
|
|
from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature
|
|
from common_util.common_util_route_geometry import RouteVertex
|
|
|
|
# 실측 정본(프로젝트 c1bb453f) 그대로 — 라벨 문자열이 종단 정본과 글자까지 같아야 한다.
|
|
LIVE_POINTS = [
|
|
{
|
|
"chainage_m": 84.3,
|
|
"source": "spacing",
|
|
"options": {"pipe_kind": "파형강관", "pipe_diameter_mm": 1000},
|
|
},
|
|
{"chainage_m": 149.73, "source": "stream", "facility": "ford_bridge"},
|
|
{"chainage_m": 200.92, "source": "spacing", "facility": "box_culvert"},
|
|
{"chainage_m": 275.71, "source": "spacing", "options": {"pipe_diameter_mm": 800}},
|
|
]
|
|
LIVE_LABELS = (
|
|
(84.3, "파형강관 D1000"),
|
|
(149.73, "세월교"),
|
|
(200.92, "BOX암거"),
|
|
(275.71, "파형강관 D800"),
|
|
)
|
|
|
|
STRAIGHT_LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]]
|
|
|
|
|
|
class _FlatSampler:
|
|
"""표고 고정 스텁 — 측점 구성만 보는 테스트라 지형은 상수로 둔다."""
|
|
|
|
def sample_xy(self, xy):
|
|
count = len(np.asarray(xy, dtype=np.float64))
|
|
return np.full(count, 100.0), np.ones(count, dtype=bool)
|
|
|
|
|
|
def _write_pipe_points(root, points, polyline=STRAIGHT_LINE, signature=None):
|
|
edits = root / "B04_PreProcess" / "drainage" / "edits"
|
|
edits.mkdir(parents=True, exist_ok=True)
|
|
vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in polyline]
|
|
(edits / "pipe_points.json").write_text(
|
|
json.dumps(
|
|
{"route_signature": signature or route_signature(vertices), "points": points},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _write_structures(root, structures):
|
|
route_dir = root / "B05_Profile" / "route"
|
|
route_dir.mkdir(parents=True, exist_ok=True)
|
|
(route_dir / "structures.json").write_text(
|
|
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
# ── 라벨 파생 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_pipe_points_become_labelled_stations(tmp_path):
|
|
assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS
|
|
|
|
|
|
def test_pipe_without_options_falls_back_to_config_defaults(tmp_path):
|
|
extras = resolve_extra_stations(tmp_path, parse_pipe_points([{"chainage_m": 30.0}]))
|
|
assert extras == ((30.0, "파형강관 D800"),)
|
|
|
|
|
|
def test_ford_pavement_is_not_an_irregular_station(tmp_path):
|
|
"""물넘이포장은 비정규 측점이 아니다 — 종단 그래프 구조물 표시 몫(2026-08-28 사용자 확정)."""
|
|
points = [{"chainage_m": 40.0, "facility": "ford_pavement"}, {"chainage_m": 60.0}]
|
|
extras = resolve_extra_stations(tmp_path, parse_pipe_points(points))
|
|
assert [chainage for chainage, _label in extras] == [60.0]
|
|
|
|
|
|
# ── 구조물 정본 A군 합류 ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_group_a_point_and_revetment_facility_join(tmp_path):
|
|
"""A군 점형은 기준 한 곳, 기슭막이(관 시설)는 시작·기준·종료 세 곳(2026-08-28).
|
|
|
|
기슭막이는 구조물 정본에서 관 지점 정본으로 옮겨갔다(`managed_by: pipe_points`) —
|
|
구간형 구조물이 아니라 관 시설 목록에서 온다.
|
|
"""
|
|
_write_structures(
|
|
tmp_path,
|
|
[{"type_id": "cross_drain_exposed", "chainage_m": 60.0, "placement": "point"}],
|
|
)
|
|
points = [
|
|
*LIVE_POINTS,
|
|
{
|
|
"chainage_m": 100.0,
|
|
"source": "user",
|
|
"facility": "revetment",
|
|
"start_m": 95.0,
|
|
"end_m": 115.0,
|
|
},
|
|
]
|
|
extras = resolve_extra_stations(tmp_path, parse_pipe_points(points))
|
|
assert (60.0, "노출형 횡단수로") in extras
|
|
assert [chainage for chainage, label in extras if label == "기슭막이"] == [95.0, 100.0, 115.0]
|
|
assert [chainage for chainage, _label in extras] == [
|
|
60.0,
|
|
84.3,
|
|
95.0,
|
|
100.0,
|
|
115.0,
|
|
149.73,
|
|
200.92,
|
|
275.71,
|
|
]
|
|
|
|
|
|
def test_other_groups_do_not_plant_stations(tmp_path):
|
|
"""C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다."""
|
|
_write_structures(
|
|
tmp_path,
|
|
[
|
|
{
|
|
"type_id": "retaining_wall",
|
|
"chainage_m": 40.0,
|
|
"start_m": 40.0,
|
|
"end_m": 60.0,
|
|
"placement": "interval",
|
|
},
|
|
{
|
|
"type_id": "pavement_concrete",
|
|
"chainage_m": 300.0,
|
|
"start_m": 295.0,
|
|
"end_m": 305.0,
|
|
"placement": "interval",
|
|
},
|
|
],
|
|
)
|
|
extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS))
|
|
assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71]
|
|
|
|
|
|
def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path):
|
|
route_dir = tmp_path / "B05_Profile" / "route"
|
|
route_dir.mkdir(parents=True, exist_ok=True)
|
|
(route_dir / "structures.json").write_text("{not json", encoding="utf-8")
|
|
assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS
|
|
|
|
|
|
# ── 관 정본 로드·지문 게이트 ───────────────────────────────────────────────
|
|
|
|
|
|
def test_missing_pipe_points_file_is_empty_not_error(tmp_path):
|
|
assert _load_pipe_points(tmp_path, STRAIGHT_LINE) == []
|
|
|
|
|
|
def test_route_signature_mismatch_drops_points_with_warning(tmp_path, caplog):
|
|
"""지문이 다르고 좌표도 없는 구 저장분만 버린다 — 침묵하지 않고 경고를 남긴다.
|
|
|
|
좌표가 있으면 그 선에 투영해 이월한다(2026-08-30). LIVE_POINTS에는 좌표가 없다.
|
|
"""
|
|
_write_pipe_points(tmp_path, LIVE_POINTS, signature="옛노선-0000")
|
|
with caplog.at_level("WARNING"):
|
|
assert _load_pipe_points(tmp_path, STRAIGHT_LINE) == []
|
|
assert any("좌표 없는 구 저장분" in record.getMessage() for record in caplog.records)
|
|
|
|
|
|
def test_matching_signature_loads_points(tmp_path):
|
|
_write_pipe_points(tmp_path, LIVE_POINTS)
|
|
assert [pipe.chainage_m for pipe in _load_pipe_points(tmp_path, STRAIGHT_LINE)] == [
|
|
84.3,
|
|
149.73,
|
|
200.92,
|
|
275.71,
|
|
]
|
|
|
|
|
|
# ── 소수 측점 횡단 — 관 · 구조물 자리마다 따로(이름 소수 2자리 · PLAN 20장) ──────────────
|
|
|
|
|
|
def _stations(extra_stations):
|
|
result = generate_sections(
|
|
STRAIGHT_LINE,
|
|
_FlatSampler(),
|
|
SectionGenerationOptions(station_interval_m=20.0, extra_stations=extra_stations),
|
|
)
|
|
return result["longitudinal"]["stations"]
|
|
|
|
|
|
def test_extra_station_in_grid_meter_keeps_both_with_own_names():
|
|
stations = _stations(((100.4, "X"),))
|
|
chainages = [station["chainage_m"] for station in stations]
|
|
assert 100.4 in chainages and 100.0 in chainages
|
|
labelled = [station for station in stations if station.get("structure") == "X"]
|
|
assert [(station["chainage_m"], station["kind"]) for station in labelled] == [
|
|
(100.4, "irregular")
|
|
]
|
|
assert cross_filename(100.4) == "cross_00100.40m.json"
|
|
assert len({cross_filename(c) for c in chainages}) == len(chainages)
|
|
|
|
|
|
def test_extra_station_on_grid_within_5mm_labels_grid_station():
|
|
stations = _stations(((100.003, "X"), (0.3, "S")))
|
|
labelled = {station["chainage_m"]: station for station in stations if station.get("structure")}
|
|
assert sorted(labelled) == [0.3, 100.0]
|
|
assert labelled[100.0]["kind"] == "regular" and labelled[0.3]["kind"] == "irregular"
|
|
|
|
|
|
def test_two_extras_in_same_meter_both_kept():
|
|
stations = _stations(((80.2, "A"), (80.4, "B")))
|
|
assert [station["chainage_m"] for station in stations if station.get("structure")] == [
|
|
80.2,
|
|
80.4,
|
|
]
|
|
assert 80.0 in [station["chainage_m"] for station in stations]
|
|
|
|
|
|
def test_live_extras_stay_irregular_and_filenames_stay_unique():
|
|
stations = _stations(LIVE_LABELS)
|
|
irregular = [station for station in stations if station["kind"] == "irregular"]
|
|
assert [station["chainage_m"] for station in irregular] == [84.3, 149.73, 200.92, 275.71]
|
|
assert [station["structure"] for station in irregular] == [
|
|
label for _chainage, label in LIVE_LABELS
|
|
]
|
|
assert len({cross_filename(station["chainage_m"]) for station in stations}) == len(stations)
|
|
|
|
|
|
# ── 단일 공급자 계약 (run_section_generation) ──────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def project(tmp_path, monkeypatch):
|
|
route_dir = tmp_path / "B05_Profile" / "route"
|
|
route_dir.mkdir(parents=True, exist_ok=True)
|
|
(route_dir / "route_main.geojson").write_text(
|
|
json.dumps({"geometry": {"type": "LineString", "coordinates": STRAIGHT_LINE}}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
"B05_Profile.B05_Profile_Engine_Sections.build_surface_sampler",
|
|
lambda *args, **kwargs: _FlatSampler(),
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def _generate(project_root, options=None):
|
|
return run_section_generation(
|
|
project_root,
|
|
"B05_Profile/route/route_main.geojson",
|
|
"csf",
|
|
"dtm",
|
|
True,
|
|
options=options or SectionGenerationOptions(station_interval_m=20.0),
|
|
)
|
|
|
|
|
|
def _saved_stations(project_root):
|
|
path = project_root / "B06_Section" / "longitudinal" / "longitudinal.json"
|
|
return json.loads(path.read_text(encoding="utf-8"))["stations"]
|
|
|
|
|
|
def test_run_section_generation_derives_stations_from_truth_files(project):
|
|
_write_pipe_points(project, LIVE_POINTS)
|
|
result = _generate(project)
|
|
|
|
stations = _saved_stations(project)
|
|
irregular = [station for station in stations if station["kind"] == "irregular"]
|
|
assert [station["chainage_m"] for station in irregular] == [84.3, 149.73, 200.92, 275.71]
|
|
assert [station["structure"] for station in irregular] == [
|
|
label for _chainage, label in LIVE_LABELS
|
|
]
|
|
|
|
cross_dir = project / "B06_Section" / "cross_sections"
|
|
for name in ("cross_00084.30m", "cross_00149.73m", "cross_00200.92m", "cross_00275.71m"):
|
|
assert (cross_dir / f"{name}.json").is_file()
|
|
# 파일 = DB 행 — 종전에는 파일 23 vs DB 19로 갈렸다.
|
|
assert len(result["cross_sections"]) == len(stations)
|
|
|
|
|
|
def test_caller_supplied_extra_stations_are_ignored(project):
|
|
"""호출자 값을 되먹이면 B04에서 지운 관이 스냅샷을 타고 부활한다 — 정본 파일만 쓴다."""
|
|
_write_pipe_points(project, LIVE_POINTS)
|
|
_generate(
|
|
project,
|
|
SectionGenerationOptions(station_interval_m=20.0, extra_stations=((10.0, "가짜"),)),
|
|
)
|
|
stations = _saved_stations(project)
|
|
assert all(station.get("structure") != "가짜" for station in stations)
|
|
assert [station["chainage_m"] for station in stations if station["kind"] == "irregular"] == [
|
|
84.3,
|
|
149.73,
|
|
200.92,
|
|
275.71,
|
|
]
|
|
|
|
|
|
def test_without_pipe_points_only_grid_stations(project):
|
|
_generate(project)
|
|
assert all(station["kind"] != "irregular" for station in _saved_stations(project))
|