fix(B05): 구조물 측점을 정본 파일에서 파생하는 단일 공급자를 세운다

[초기화] 후 관 지점 횡단(비정규 측점)이 통째로 사라지던 문제. 측점 정본은
longitudinal.json stations + cross_*.json 파일인데, 그 파일을 쓰는
run_section_generation 에는 공급자가 없었다. 유일한 공급자가 프론트
[임시저장]이라 초기화·반폭 재생성이 파일을 다시 쓸 때마다 측점이 날아갔다
(실측: 초기화 직후 cross 23 -> 19, irregular 4 -> 0).

- resolve_extra_stations 신설 — 관 정본(pipe_points.json) + 구조물 정본 A군에서
  매번 파생한다. 라벨 형식은 프론트 structureLabel()과 같다. 물넘이포장은
  비정규 측점으로 올리지 않는다(2026-08-28 사용자 확정).
- run_section_generation 이 generate_sections 앞에서 이 목록을 심는다. 호출자가
  넘긴 extra_stations 는 무시한다 — DB 스냅샷으로 되쓰이는 값이라 되먹이면
  B04에서 지운 관이 파일 정본을 이기고 부활한다.
- _load_pipe_anchors -> _load_pipe_points. 계획선 정착과 측점 생성이 같은 한 번의
  읽기를 쓴다. 지문 불일치 경고에 버린 건수를 남겨 침묵 실패를 없앤다.
- B06_Section_Router._extra_stations 삭제(자기참조라 늘 빈 값이었다).
  706 -> 689줄로 700줄 규칙 위반도 함께 해소.
- 격자와 같은 정수 미터인 비정규 측점은 격자로 스냅한다 — cross_*.json 파일명이
  정수 미터라 측점 수와 파일 수가 어긋났다.
- 구조물 이관 필터를 A군 6종 라벨로 넓힌다 — 서버가 심은 세월교·BOX암거 라벨을
  "기타"로 재이관하던 고스트를 막는다.

검증: pytest 229 passed / 7 skipped(신규 14건). 공용 브라우저 실측 — 초기화 직후
임시저장 없이 cross 23 / irregular 4(라벨 일치), B06 카드 23장, 반폭 재생성
20->25->20 에도 23 유지, DB cross_sections 23행.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 08:38:58 +09:00
co-authored by Claude Opus 5
parent 3a5680fd76
commit 0979927dfc
4 changed files with 113 additions and 39 deletions
+86 -18
View File
@@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB
import json
import logging
from dataclasses import replace
from pathlib import Path
from typing import Any
@@ -20,7 +21,12 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
PipePoint,
parse_pipe_points,
pipe_anchor_clearances,
route_signature,
@@ -33,6 +39,8 @@ from config.config_system import (
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
FOREST_ROAD_PROFILE_CRITERIA,
PIPE_DEFAULT_DIAMETER_MM,
PIPE_DEFAULT_TYPE,
)
logger = logging.getLogger(__name__)
@@ -93,10 +101,11 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
}
def _load_pipe_anchors(
project_root: Path, polyline: list[list[float]]
) -> tuple[list[float], dict[float, float]]:
"""배수유역도가 확정한 배관 배치 측점(누가거리)을 읽는다.
def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[PipePoint]:
"""배수유역도가 확정한 계곡 통과 시설(관 지점 정본)을 읽는다.
계획선 정착과 구조물 측점 생성이 **같은 한 번의 읽기**를 쓴다 — 따로 읽으면 계획선이
물린 자리와 측점 자리가 어긋난다.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다
(옛 노선의 배관 자리로 계획선을 앉히면 전부 어긋난다). 파일이 없거나 못 읽으면 빈 목록.
@@ -109,12 +118,12 @@ def _load_pipe_anchors(
/ DRAINAGE_PIPE_POINTS_FILENAME
)
if not path.is_file():
return [], {}
return []
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path)
return [], {}
return []
vertices = [
RouteVertex(
x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0
@@ -122,13 +131,57 @@ def _load_pipe_anchors(
for p in polyline
]
if str(document.get("route_signature") or "") != route_signature(vertices):
logger.info("B05 계획선: 노선이 바뀌어 저장된 관 지점을 쓰지 않습니다.")
return [], {}
points = parse_pipe_points(document.get("points"))
# 시설 제원이 요구하는 최소 여유(관경+토피 등)를 함께 넘긴다 — 계획선이 그만큼
# 들려야 관·구체가 들어갈 자리가 생긴다(2026-08-23 사용자 지시).
clearances = {chainage: clearance for chainage, clearance in pipe_anchor_clearances(points)}
return [pipe.chainage_m for pipe in points], clearances
# 계획선 정착과 구조물 측점이 함께 빠지므로 버린 건수를 남긴다(침묵 실패 금지).
logger.warning(
"B05 계획선: 노선 지문이 달라 저장된 관 지점 %d건을 쓰지 않습니다.",
len(document.get("points") or []),
)
return []
return parse_pipe_points(document.get("points"))
def resolve_extra_stations(
project_root: Path, pipes: list[PipePoint]
) -> tuple[tuple[float, str], ...]:
"""구조물(비정규) 측점의 **단일 공급자** — 관 정본 + 구조물 정본 A군에서 파생한다.
종전에는 이 목록을 서버에 알려 주는 곳이 프론트 [임시저장] 한 줄뿐이었다. 그래서
초기화·재생성이 종횡단 파일을 다시 쓸 때마다 구조물 측점이 통째로 사라졌다
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다
(2026-08-28 사용자 확정).
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
되짚기 때문이다(B05_Profile_Structures_Migration).
"""
types = structure_type_map()
derived: list[tuple[float, str]] = []
for pipe in pipes:
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
continue
if pipe.facility == PIPE_FACILITY_PIPE:
values = pipe.options or {}
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
label = f"{kind} D{diameter}"
else:
definition = types.get(pipe.facility)
if definition is None:
continue
label = definition.name
derived.append((float(pipe.chainage_m), label))
# A군 수동 구조물(노출형 횡단수로·개거)도 같은 자리에 측점이 필요하다. 관 정본이
# 관리하는 타입(managed_by)은 structures.json에 저장되지 않아 중복되지 않는다.
try:
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition and definition.group == "A" and not definition.managed_by:
derived.append((float(structure.anchor_m()), definition.name))
except Exception:
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
return tuple(sorted(derived))
def _append_design_profiles(
@@ -249,6 +302,9 @@ def run_section_generation(
) -> dict[str, Any]:
"""종횡단을 생성·저장하고 DB 기록용 데이터를 반환한다.
비정규(구조물) 측점은 정본 파일에서 해석하며 **호출자 옵션의 extra_stations는 무시한다**
(단일 공급자 — resolve_extra_stations).
반환 dict:
- longitudinal: {file_path, data(요약)}
- cross_sections: [{chainage_m, sequence_num, data(요약), cross_section_file_path}]
@@ -258,10 +314,21 @@ def run_section_generation(
models_dir = project_root / _MODELS_SUBDIR
sampler = build_surface_sampler(models_dir, filter_key, method, smooth)
# 구조물(비정규) 측점의 단일 공급자 — 정본 파일에서 매번 다시 만든다. 호출자가 넘긴
# extra_stations는 쓰지 않는다: 이 옵션은 아래 long_summary["options"]로 DB에 되쓰이는
# 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다.
pipes = _load_pipe_points(project_root, polyline)
try:
extras = resolve_extra_stations(project_root, pipes)
except Exception:
logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)")
extras = ()
logger.info("B05 종횡단: 구조물 측점 %d건 파생(관 %d건)", len(extras), len(pipes))
result = generate_sections(
polyline,
sampler,
options,
replace(options or SectionGenerationOptions(), extra_stations=extras),
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
)
@@ -273,14 +340,15 @@ def run_section_generation(
cross_dir.mkdir(parents=True, exist_ok=True)
# 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다)
pipe_anchors = _load_pipe_anchors(project_root, polyline)
grade_summary = _append_design_profiles(
result["longitudinal"],
grade_options,
(result.get("options") or {}).get("station_interval_m"),
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점.
pipe_chainages=pipe_anchors[0],
pipe_clearances=pipe_anchors[1],
# 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점(측점 생성과 같은 목록).
pipe_chainages=[pipe.chainage_m for pipe in pipes],
# 시설 제원이 요구하는 최소 여유(관경+토피 등) — 계획선이 그만큼 들려야 관·구체가
# 들어갈 자리가 생긴다(2026-08-23 사용자 지시).
pipe_clearances=dict(pipe_anchor_clearances(pipes)),
)
# 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적.
try:
@@ -145,6 +145,15 @@ def generate_sections(
if 0.0 <= float(chainage) <= total
}
if extras:
# 횡단 파일명이 정수 미터 반올림이라(cross_filename) 1m 안의 두 측점은 같은 파일을
# 덮어써 측점 수와 횡단 파일 수가 어긋난다. 같은 정수 미터면 격자 측점으로 스냅해
# 라벨만 얹고(아래 station["structure"] 부착), 비정규끼리 겹치면 앞선 것만 남긴다.
# ponytail: 파일명이 정수 미터인 동안의 가드. 소수 1자리 규칙으로 바꾸면 지운다.
buckets = {int(round(float(value))): round(float(value), 6) for value in station_chainage}
snapped: dict[float, str] = {}
for chainage in sorted(extras):
snapped.setdefault(buckets.setdefault(int(round(chainage)), chainage), extras[chainage])
extras = snapped
station_chainage = np.unique(
np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
)
@@ -15,7 +15,11 @@
import re
from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
load_structure_types,
structure_type_map,
)
PIPE_STRUCTURE_NAME = "배관"
# 기존 타입 이름 → 신규 type_id.
@@ -33,12 +37,22 @@ _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE)
_ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
def _is_pipe(entry: dict[str, Any]) -> bool:
def _managed_elsewhere_labels() -> set[str]:
"""A군(계곡 통과 시설·횡단배수) 표시 이름 — 서버가 종단 정본에 직접 심는 라벨이라
구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). 레지스트리에서 뽑아 목록이
바뀌어도 따라간다(load_structure_types는 lru_cache).
"""
return {item.name for item in load_structure_types() if item.group == "A"}
def _is_managed_elsewhere(entry: dict[str, Any]) -> bool:
"""다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가."""
label = str(entry.get("structure", "")).strip()
return (
entry.get("origin") == "pipe"
or entry.get("structureType") == PIPE_STRUCTURE_NAME
or label == PIPE_STRUCTURE_NAME
or label in _managed_elsewhere_labels()
or bool(_PIPE_LABEL.match(label))
)
@@ -78,7 +92,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[Struct
seen: set[tuple[str, float]] = set()
for entry in entries:
if _is_pipe(entry):
if _is_managed_elsewhere(entry):
continue
chainage = float(entry.get("chainage_m", 0.0))
+1 -18
View File
@@ -367,26 +367,10 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes
)
def _extra_stations(stored_options: dict[str, Any] | None) -> tuple[tuple[float, str], ...]:
"""구조물(비정규) 측점 목록 — 재생성해도 유지돼야 한다.
옵션 스냅샷(data.options.extra_stations)이 비정규 측점의 단일 소스다. 재생성 옵션에
이 목록을 빼먹으면 구조물 측점이 통째로 지워진다 — 반폭 확대만으로 구조물 횡단도가
사라졌다(2026-08-23 사용자 보고).
"""
snapshot = (stored_options or {}).get("extra_stations") or []
return tuple(
(float(entry[0]), str(entry[1]))
for entry in snapshot
if isinstance(entry, (list, tuple)) and len(entry) >= 2
)
def _regeneration_options(
stored_options: dict[str, Any] | None,
stage_params: dict[str, Any] | None,
cross_half_width_m: float,
extra_stations: tuple[tuple[float, str], ...] = (),
) -> SectionGenerationOptions:
"""DB 저장 옵션(단일 소스) → stage 2 params → config 순으로 유지하고 반폭만 교체한다."""
defaults = SectionGenerationOptions()
@@ -402,7 +386,6 @@ def _regeneration_options(
cross_sample_interval_m=pick("cross_sample_interval_m", defaults.cross_sample_interval_m),
long_sample_interval_m=pick("long_sample_interval_m", defaults.long_sample_interval_m),
include_endpoint=defaults.include_endpoint,
extra_stations=extra_stations,
)
@@ -439,11 +422,11 @@ async def regenerate_sections(
surface_params["source_filter"],
surface_params["method"],
bool(surface_params["smooth"]),
# 비정규 측점은 run_section_generation이 정본 파일에서 해석한다(단일 공급자).
options=_regeneration_options(
stored_options,
stage_params,
request.cross_half_width_m,
_extra_stations(stored_options),
),
# 계획선까지 함께 재계산·저장 — 없으면 계획 횡단도선·유토곡선이 사라진다.
grade_options=_regeneration_grade_options(stage_params),