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:
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user