"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산.""" import json import logging from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Engine_Sections import cross_filename from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from common_util.common_util_cross_berm import ( BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, BermSpec, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M logger = logging.getLogger(__name__) # 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과 # 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다. _FORD_DROP_TOLERANCE_M = 0.02 PREVIEW_DESIGN_FIELDS = ( "ground_type", "roadbed_width_m", "cut_area_m2", "cut_soil_area_m2", "cut_rock_area_m2", "cut_rock_kind", "fill_area_m2", "fill_ground_slope", "design_elevation_m", "slope_unclosed", ) def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]: mapping: dict[float, bool] = {} stations = longitudinal.get("stations") for station in stations if isinstance(stations, list) else []: suggested = station.get("pavement_suggested") if isinstance(suggested, bool): mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested return mapping def pavement_ranges(project_root: Path) -> list[tuple[float, float]]: """포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수). 포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은 더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다. 물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다. 읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다. """ ranges: list[tuple[float, float]] = [] try: types = structure_type_map() for structure in load_structures(str(project_root))[1]: definition = types.get(structure.type_id) if definition is None or definition.group != "G": continue anchor = structure.chainage_m start = structure.start_m if structure.start_m is not None else anchor end = structure.end_m if structure.end_m is not None else anchor if start is None or end is None: continue ranges.append((min(float(start), float(end)), max(float(start), float(end)))) except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)") for chainage, spec in load_culvert_sets(project_root).items(): if spec.get("type") != "ford_pavement": continue half = float(spec.get("span_m") or 0.0) / 2.0 ranges.append((chainage - half, chainage + half)) return ranges def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: """세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)]. 구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼 내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로 관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다). """ if project_root is None: return [] drops: list[tuple[float, float]] = [] try: for chainage, spec in load_culvert_sets(project_root).items(): if spec.get("type") != "ford": continue depth = float(spec.get("overflow_depth_m") or 0.0) if depth > 0: drops.append((chainage, depth)) except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)") return drops def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: """그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다.""" for at, depth in drops: if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M: return depth return 0.0 def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool: """이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장).""" for start, end in ranges: if start - 1e-9 <= chainage_m <= end + 1e-9: return True return bool(stored) if isinstance(stored, bool) else False # 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.** # # 브라우저 쪽 짝은 `B06_Section_Cross_Refresh.ts` 의 `PRESERVED_KEYS` 다(+ `status`· # `pavement_suggested`, 이 둘은 부르는 쪽이 따로 붙인다). 새 사용자 조작값을 만들면 # **두 곳 모두**에 넣을 것 — 빠뜨리면 재계산이 조용히 지운다. # # ⚠ `extra_spans`(다단 구간값)가 실제로 그렇게 빠져 있었다(2026-09-07 발견). 브라우저는 # 살렸는데 서버 두 경로(포장 강제·세월교 노면 하강)와 단측점 갱신은 안 살려, 그 측점이 # 다시 계산되면 사용자가 넣은 단별 구간값이 사라졌다. USER_TOUCHED_KEYS = ( "display_half_width_m", "inlet_structure", "basin_adjust", "revet_adjust", "ford_adjust", "box_adjust", "extra_wall_counts", "extra_spans", "revet_link_detached", "revet_follow_grade", # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", ) def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None: """확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값). 사용자가 「표준 횡단면 설정」에서 고친 값은 확정 시 종단 정본 options에 실린다. 포장 강제 재계산도 같은 값을 써야 카드가 패널과 어긋나지 않는다(2026-08-28). """ options = (longitudinal_row or {}).get("data") or {} options = options.get("options") if isinstance(options, dict) else None standard = options.get("standard_cross_section") if isinstance(options, dict) else None return standard if isinstance(standard, dict) else None def enforce_pavement_ranges( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path, standard: dict[str, Any] | None = None, ) -> int: """포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다. 사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정). 포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다. 사용자 조작값(반폭·구조물 조정)은 그대로 승계한다. """ ranges = pavement_ranges(project_root) if not ranges: return 0 ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: design = section.get("design") if not isinstance(design, dict) or design.get("paved"): continue chainage = float(section.get("chainage_m", 0.0)) if not paved_at(chainage, ranges): continue try: recomputed = compute_cross_design( section.get("samples", []), design_elevation_from_longitudinal(longitudinal, chainage), ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=True, standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=stored_berm(design), **curve_widening_args(section), ) except (ValueError, KeyError): continue for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): if design.get(key) is not None: recomputed[key] = design[key] section["design"] = recomputed changed += 1 return changed def enforce_ford_surface_drops( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path, standard: dict[str, Any] | None = None, ) -> int: """세월교가 앉은 측점의 계획고를 월류 높이만큼 내려 다시 계산한다. 저장분은 월류 높이를 넣기 전에 계산된 것이라 노면이 그대로다 — 포장 구간과 같은 방식으로 **저장분과 지금 값이 다를 때만** 다시 계산한다(2026-08-30 사용자 확정). 월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다. """ drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: design = section.get("design") if not isinstance(design, dict): continue chainage = float(section.get("chainage_m", 0.0)) wanted = ford_drop_at(chainage, drops) stored = float(design.get("surface_drop_m") or 0.0) if abs(wanted - stored) < 1e-6: continue try: recomputed = compute_cross_design( section.get("samples", []), design_elevation_from_longitudinal(longitudinal, chainage), ground_type=str(design.get("ground_type") or "ripping_rock"), section_mode=str(design.get("section_mode") or "left_cut"), ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=bool(design.get("paved", False)), standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, berm=stored_berm(design), **curve_widening_args(section), ) except (ValueError, KeyError): continue for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): if design.get(key) is not None: recomputed[key] = design[key] section["design"] = recomputed changed += 1 return changed def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: mapping: dict[float, str] = {} stations = longitudinal.get("stations") for station in stations if isinstance(stations, list) else []: side = station.get("uphill_side") if side in ("left", "right"): mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut" return mapping def resolve_longitudinal_path(project_root: Path, longitudinal_file_path: str) -> Path: """종단 정본 파일 경로를 검증해 돌려준다 — 저장소 밖 경로를 막는다.""" root = project_root.resolve() path = (root / longitudinal_file_path).resolve() if root not in path.parents: raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") if not path.is_file(): raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") return path def read_cross_design_inputs( project_root: Path, longitudinal_file_path: str, chainage_m: float, preloaded: tuple[Path, dict[str, Any], dict[float, bool]] | None = None, ) -> tuple[list[dict], float | None, bool, dict[str, Any]]: """(지반 샘플, 계획고, 포장 제안, 측점 기록) — 측점 기록은 곡선부 확폭 입력을 담고 있다. `preloaded` 는 (종단 경로, 종단 내용, 포장 제안표)다. 여러 측점을 잇달아 볼 때 종단 정본을 측점마다 다시 읽지 않게 넘긴다 — 그 재읽기가 측점당 13.8ms 였다 (2026-09-06 실측, 측구 방향 역반영 루프). """ if preloaded is not None: longitudinal_path, longitudinal, pavement = preloaded else: longitudinal_path = resolve_longitudinal_path(project_root, longitudinal_file_path) longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) pavement = pavement_suggestions(longitudinal) design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) suggested = pavement.get(round(chainage_m, 3), False) cross_dir = longitudinal_path.parent.parent / "cross_sections" cross_path = (cross_dir / cross_filename(chainage_m)).resolve() if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file(): raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") cross = json.loads(cross_path.read_text(encoding="utf-8")) samples = cross.get("samples") if isinstance(cross, dict) else None if not isinstance(samples, list): raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") return samples, design_elevation, suggested, cross def attach_default_designs( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path | None = None, standard: dict[str, Any] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] ford_drops = ford_surface_drops(project_root) for section in cross_sections: if section.get("design"): continue try: chainage = float(section.get("chainage_m", 0.0)) suggested = pavement.get(round(chainage, 3), False) design = compute_cross_design( section.get("samples", []), design_elevation_from_longitudinal(longitudinal, chainage), ground_type="ripping_rock", section_mode=modes.get(round(chainage, 3), "left_cut"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). paved=paved_at(chainage, paved_ranges), standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage, ford_drops), **curve_widening_args(section), ) design.update(status="provisional", pavement_suggested=suggested) section["design"] = design except (ValueError, KeyError): continue def stored_berm(stored: dict[str, Any]) -> BermSpec | None: """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" spec = stored.get("berm") if not isinstance(spec, dict): return None try: return BermSpec( width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), ) except (TypeError, ValueError): return None def recompute_designs_for_alignment( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], stored_designs: list[dict[str, Any]], standard: dict[str, Any] | None, rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, berms: dict[str, dict[str, float]] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] ford_drops = ford_surface_drops(project_root) stored_by_chainage = { round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs } session_offsets: dict[float, float] = {} for raw_key, offset in (rock_boundary_offsets or {}).items(): try: session_offsets[round(float(raw_key), 3)] = float(offset) except (TypeError, ValueError): continue # 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로). session_berms: dict[float, BermSpec] = {} for raw_key, spec in (berms or {}).items(): try: session_berms[round(float(raw_key), 3)] = BermSpec( width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), ) except (TypeError, ValueError, AttributeError): continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) key = round(chainage, 3) stored = stored_by_chainage.get(key) or {} suggested = pavement.get(key, False) try: design = compute_cross_design( section.get("samples", []), design_elevation_from_longitudinal(longitudinal, chainage), ground_type=str(stored.get("ground_type") or "ripping_rock"), section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")), ditch_side=stored.get("ditch_side"), ditch_type=str(stored.get("ditch_type") or "standard"), paved=paved_at(chainage, paved_ranges, stored.get("paved")), standard=standard, rock_boundary_offset_m=session_offsets.get( key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=session_berms.get(key) or stored_berm(stored), **curve_widening_args(section), ) except (ValueError, KeyError): continue design.update(status="provisional", pavement_suggested=suggested) if stored.get("display_half_width_m") is not None: design["display_half_width_m"] = stored["display_half_width_m"] if stored.get("inlet_structure") is not None: design["inlet_structure"] = stored["inlet_structure"] if stored.get("basin_adjust") is not None: design["basin_adjust"] = stored["basin_adjust"] if stored.get("ford_adjust") is not None: design["ford_adjust"] = stored["ford_adjust"] if stored.get("box_adjust") is not None: design["box_adjust"] = stored["box_adjust"] section["design"] = design def compute_default_designs( project_root: Path, longitudinal_file_path: str, chainages: list[float], standard: dict[str, Any] | None = None, ) -> list[tuple[float, dict[str, Any]]]: """미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다. standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다. 계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함). """ root = project_root.resolve() longitudinal_path = (root / longitudinal_file_path).resolve() if root not in longitudinal_path.parents or not longitudinal_path.is_file(): return [] longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) cross_dir = longitudinal_path.parent.parent / "cross_sections" default_modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(root) ford_drops = ford_surface_drops(root) results: list[tuple[float, dict[str, Any]]] = [] for chainage_m in chainages: cross_path = cross_dir / cross_filename(chainage_m) if not cross_path.is_file(): continue try: cross = json.loads(cross_path.read_text(encoding="utf-8")) samples = cross.get("samples") if not isinstance(samples, list): continue suggested = pavement.get(round(chainage_m, 3), False) design = compute_cross_design( samples, design_elevation_from_longitudinal(longitudinal, chainage_m), ground_type="ripping_rock", section_mode=default_modes.get(round(chainage_m, 3), "left_cut"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). paved=paved_at(chainage_m, paved_ranges), standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage_m, ford_drops), **curve_widening_args(cross), ) design["status"] = "provisional" design["pavement_suggested"] = suggested results.append((chainage_m, design)) except (ValueError, KeyError, OSError, json.JSONDecodeError): continue return results