diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index fb52205b..c5786d16 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 631b8f86..94b4eb5a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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)] ) diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 1b9800dc..25b1f8da 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -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)) diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 9d7bd852..713f2522 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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),