From 0979927dfca1f2ec6fac1873fc1c046cb94c50f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 28 Aug 2026 08:38:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=EC=9D=84=20=EC=A0=95=EB=B3=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=8B=A8=EC=9D=BC=20=EA=B3=B5=EA=B8=89=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EC=84=B8=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [초기화] 후 관 지점 횡단(비정규 측점)이 통째로 사라지던 문제. 측점 정본은 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) --- B05_Profile/B05_Profile_Engine_Sections.py | 104 +++++++++++++++--- .../B05_Profile_Engine_Sections_Core.py | 9 ++ .../B05_Profile_Structures_Migration.py | 20 +++- B06_Section/B06_Section_Router.py | 19 +--- 4 files changed, 113 insertions(+), 39 deletions(-) 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),