"""B06 측점 표준횡단 설계 계산 엔진. 지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단 설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가 버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서 확정치로 대체된다. 좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우. 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ from typing import Any from config.config_system import ( SECTION_CARRIAGEWAY_WIDTH_M, SECTION_DESIGN_TEMPLATES, SECTION_DITCH_SIDES, SECTION_FILL_SLOPE_RATIO, SECTION_GROUND_TYPE_PRESET, SECTION_MODES, SECTION_ROADBED_WIDTH_M, ) def _side_role(section_mode: str) -> tuple[str, str]: """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" if section_mode == "left_cut": return "cut", "fill" if section_mode == "right_cut": return "fill", "cut" if section_mode == "both_cut": return "cut", "cut" if section_mode == "both_fill": return "fill", "fill" raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str: """측구(배수) 배치 측을 결정한다. 편절편성은 절토측이 곧 측구측이라 자동 결정하고, 양절·양성은 배수 방향을 사용자 지정(ditch_side)에 맡긴다(미지정 시 좌측 기본). """ if section_mode == "left_cut": return "left" if section_mode == "right_cut": return "right" if ditch_side in SECTION_DITCH_SIDES: return ditch_side return "left" def _design_elevation_on_side( offset_m: float, ground_m: float, role: str, design_elevation_m: float, half_width_m: float, cut_slope_ratio: float, fill_slope_ratio: float, ) -> float: """노체 밖 한 offset의 설계 표고를 절토/성토 규칙으로 계산한다. 절토측: 노면 가장자리에서 경사면이 위로 올라가다 지반선을 만나면 지반을 따른다. 성토측: 가장자리에서 경사면이 아래로 내려가다 지반선을 만나면 지반을 따른다. """ edge_distance = abs(offset_m) - half_width_m if role == "cut": slope_line = design_elevation_m + edge_distance / cut_slope_ratio return min(slope_line, ground_m) fill_line = design_elevation_m - edge_distance / fill_slope_ratio return max(fill_line, ground_m) def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]: """오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 (절토, 성토) 면적을 반환한다. diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서 나눠 절·성토가 섞이지 않게 한다. """ cut_area = 0.0 fill_area = 0.0 for index in range(1, len(offsets)): x0, x1 = offsets[index - 1], offsets[index] d0, d1 = diffs[index - 1], diffs[index] width = x1 - x0 if width <= 0: continue if d0 == 0 and d1 == 0: continue if d0 * d1 < 0: # 부호 변화: 영교점에서 두 삼각형으로 분리 zero_ratio = d0 / (d0 - d1) x_zero = x0 + width * zero_ratio left_area = 0.5 * (x_zero - x0) * abs(d0) right_area = 0.5 * (x1 - x_zero) * abs(d1) if d0 > 0: cut_area += left_area fill_area += right_area else: fill_area += left_area cut_area += right_area continue area = 0.5 * (d0 + d1) * width if area >= 0: cut_area += area else: fill_area += -area return cut_area, fill_area def compute_cross_design( samples: list[dict[str, Any]], design_elevation_m: float | None, *, ground_type: str, section_mode: str, ditch_side: str | None = None, roadbed_width_m: float = SECTION_ROADBED_WIDTH_M, fill_slope_ratio: float = SECTION_FILL_SLOPE_RATIO, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플. design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가. """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") if section_mode not in SECTION_MODES: raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") if design_elevation_m is None: raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.") preset_key = SECTION_GROUND_TYPE_PRESET[ground_type] preset = SECTION_DESIGN_TEMPLATES[preset_key] cut_slope_ratio = float(preset["cut_slope_ratio"]) ditch_width_m = float(preset["ditch_width_m"]) ditch_depth_m = float(preset["ditch_depth_m"]) half_width_m = roadbed_width_m / 2.0 left_role, right_role = _side_role(section_mode) resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side) valid = sorted( ( (float(s["offset_m"]), float(s["elevation_m"])) for s in samples if s.get("valid") is not False and s.get("offset_m") is not None and s.get("elevation_m") is not None ), key=lambda pair: pair[0], ) if len(valid) < 2: raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.") offsets: list[float] = [] diffs: list[float] = [] design_line: list[dict[str, float]] = [] for offset_m, ground_m in valid: if abs(offset_m) <= half_width_m + 1e-9: design_z = design_elevation_m else: role = left_role if offset_m > 0 else right_role design_z = _design_elevation_on_side( offset_m, ground_m, role, design_elevation_m, half_width_m, cut_slope_ratio, fill_slope_ratio, ) offsets.append(offset_m) diffs.append(ground_m - design_z) design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)}) cut_area, fill_area = _trapezoid_areas(offsets, diffs) # 측구는 절토측에서 굴착되므로 절토 단면적에 사다리꼴 근사로 가산한다. ditch_area = ditch_depth_m * (ditch_width_m + ditch_width_m * 0.5) / 2.0 cut_area += ditch_area return { "ground_type": ground_type, "geometry_preset": preset_key, "section_mode": section_mode, "ditch_side": resolved_ditch_side, "cut_slope_ratio": round(cut_slope_ratio, 4), "fill_slope_ratio": round(fill_slope_ratio, 4), "roadbed_width_m": round(roadbed_width_m, 4), "carriageway_width_m": round(SECTION_CARRIAGEWAY_WIDTH_M, 4), "ditch": {"width_m": ditch_width_m, "depth_m": ditch_depth_m}, "design_elevation_m": round(float(design_elevation_m), 4), "cut_area_m2": round(cut_area, 4), "fill_area_m2": round(fill_area, 4), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, } def design_elevation_from_longitudinal( longitudinal: dict[str, Any], chainage_m: float ) -> float | None: """종단 계획선(design_profiles) 샘플을 chainage 기준 선형보간해 계획고를 구한다. 프론트 designElevationAt과 동일 규칙(범위 밖 양 끝값 클램프). 계획선이 없으면 None을 반환해 지반고 폴백/오류 처리를 호출부에 맡긴다. """ profiles = longitudinal.get("design_profiles") if isinstance(longitudinal, dict) else None if not isinstance(profiles, list) or not profiles: return None samples = [ s for s in profiles[0].get("samples", []) if isinstance(s.get("elevation_m"), (int, float)) and isinstance(s.get("chainage_m"), (int, float)) ] if not samples: return None if chainage_m <= samples[0]["chainage_m"]: return float(samples[0]["elevation_m"]) last = samples[-1] if chainage_m >= last["chainage_m"]: return float(last["elevation_m"]) for index in range(1, len(samples)): previous = samples[index - 1] current = samples[index] if chainage_m > current["chainage_m"]: continue span = current["chainage_m"] - previous["chainage_m"] if span <= 0: return float(current["elevation_m"]) ratio = (chainage_m - previous["chainage_m"]) / span return float( previous["elevation_m"] + (current["elevation_m"] - previous["elevation_m"]) * ratio ) return float(last["elevation_m"])