""" config_system.py 백엔드 작동 제어 및 알고리즘 파라미터 모든 제어 변수는 여기서 관리. 코드 내 하드코딩 금지. """ import os from pathlib import Path from dotenv import load_dotenv PROJECT_ROOT = Path(__file__).resolve().parent.parent load_dotenv(PROJECT_ROOT / ".env") os.environ["PYTHONUTF8"] = "1" # ───────────────────────────────────────────────────────────────────────── # 1. FastAPI 서버 # ───────────────────────────────────────────────────────────────────────── SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0") SERVER_PORT = int(os.getenv("SERVER_PORT", "8000")) DEBUG = os.getenv("DEBUG", "False").lower() == "true" # 정적 파일 서빙 (프론트엔드 빌드 결과) STATIC_DIR = os.path.join(os.path.dirname(__file__), "node_modules", ".build") STATIC_URL = "/static" # ───────────────────────────────────────────────────────────────────────── # 2. 로깅 # ───────────────────────────────────────────────────────────────────────── LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") # ───────────────────────────────────────────────────────────────────────── # 3. 데이터베이스 (MariaDB) # ───────────────────────────────────────────────────────────────────────── DB_HOST = os.getenv("DB_HOST", "localhost") DB_PORT = int(os.getenv("DB_PORT", "3306")) DB_NAME = os.getenv("DB_NAME", "aislo_db") DB_USER = os.getenv("DB_USER", "aislo") DB_PASSWORD = os.getenv("DB_PASSWORD", "aislo") # asyncmy 연결 풀 설정 DB_POOL_MIN = int(os.getenv("DB_POOL_MIN", "5")) DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) # ───────────────────────────────────────────────────────────────────────── # 4. 파일 업로드 제한 # ───────────────────────────────────────────────────────────────────────── UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024))) UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "5")) UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024))) UPLOAD_ALLOWED_EXT = [".las", ".laz", ".tif", ".tfw", ".prj", ".dxf"] CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( os.getenv("SEND_ANALYSIS_COMPLETION_EMAIL", "True").lower() == "true" ) # ───────────────────────────────────────────────────────────────────────── # 5. 지형 분석 알고리즘 파라미터 # ───────────────────────────────────────────────────────────────────────── # Trimesh 메쉬 생성 MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 미터 단위 MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0")) SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) # CSF (Cloth Simulation Filter) 지면 분류 파라미터 SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5")) SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1")) SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65")) SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5")) SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150")) SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true" SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float( os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8") ) # PMF (Progressive Morphological Filter) 지면 분류 파라미터 SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0")) SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40")) SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3")) SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0")) SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5")) # RANSAC (Local plane fitting) 지면 분류 파라미터 SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3")) SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3")) SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100")) SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0")) SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) # ───────────────────────────────────────────────────────────────────────── # 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) # ───────────────────────────────────────────────────────────────────────── # 빌드 대상 지면 필터·표현 방식 SURFACE_MODEL_SOURCE_FILTERS = tuple( os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "grid_min_z,csf,pmf").split(",") ) # dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) SURFACE_MODEL_PRECOMPUTE = tuple( os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") ) SURFACE_MODEL_SMOOTHING_METHODS = tuple( os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") ) SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0")) # footprint(외곽) 산출 SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) SURFACE_KEEP_LARGEST_FOOTPRINT = ( os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" ) SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000")) # 표현별 파라미터 (old 버전 검증값 기준 — PLAN F) SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000")) SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") ) SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") ) SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) # 스무딩 파라미터 SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0")) SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float( os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5") ) SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int( os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10") ) SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5")) SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) # 등고선 파라미터 SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) # 일반 사용자 WF1 자동 확정 기본값 SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") SURFACE_CONFIRM_DEFAULT_SMOOTH = ( os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" ) def build_surface_model_config() -> dict: """지표면 모델 파이프라인이 사용하는 config dict를 조립한다.""" return { "source_filters": list(SURFACE_MODEL_SOURCE_FILTERS), "precompute": list(SURFACE_MODEL_PRECOMPUTE), "smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS), "sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS, "footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M, "footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M, "boundary_inset_meters": SURFACE_BOUNDARY_INSET_M, "keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT, "tile_size_meters": SURFACE_TILE_SIZE_M, "max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES, "tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS, "dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M, "nurbs_degree": SURFACE_NURBS_DEGREE, "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, "smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M, "smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, "smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, "smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS, "smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA, "smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU, "contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M, "contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M, } # ───────────────────────────────────────────────────────────────────────── # 5-3. 경로 설계 파라미터 (B05 WF2) # ───────────────────────────────────────────────────────────────────────── # 비용 함수 가중치 ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0")) ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0")) ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5")) ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5")) ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0")) ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0")) # 격자·경사·비용 제약 ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14")) ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18")) ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0")) ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0")) ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) ROUTE_GRADE_CLASSES = ("trunk", "branch", "work") # 임도 등급별 종단경사 한계 및 최소 곡선반지름 FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "branch": 0.28, "work": 0.40} FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "branch": 10.0, "work": 6.0} # 대안(정속경사) 파라미터 ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005")) # 지형 skeleton (능선/계곡) 파라미터 SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500")) SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int( os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000") ) SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500")) SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000") ) SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) # ───────────────────────────────────────────────────────────────────────── # 5-4. 종횡단 생성 파라미터 (B06 WF3) # ───────────────────────────────────────────────────────────────────────── SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0")) SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} # ───────────────────────────────────────────────────────────────────────── # 5-4-1. 횡단 표준단면 설계 기준 (B06 WF3) # # 출처: 첨부 표준횡단도(토사/암 구간). 노면폭 4.5m = 길어깨 0.5 + 차도 3.5 + 길어깨 0.5. # 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종 # 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분). # 성토 경사는 지반유형과 무관하게 고정값을 적용한다. # ───────────────────────────────────────────────────────────────────────── SECTION_ROADBED_WIDTH_M = float(os.getenv("SECTION_ROADBED_WIDTH_M", "4.5")) SECTION_CARRIAGEWAY_WIDTH_M = float(os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", "3.5")) # 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정. SECTION_FILL_SLOPE_RATIO = float(os.getenv("SECTION_FILL_SLOPE_RATIO", "1.2")) # 표준단면 기하 프리셋: 절토경사(수평:수직=ratio:1)와 측구 규격(m). SECTION_DESIGN_TEMPLATES = { "soil": {"cut_slope_ratio": 1.2, "ditch_width_m": 1.0, "ditch_depth_m": 0.4}, "rock": {"cut_slope_ratio": 0.6, "ditch_width_m": 0.79, "ditch_depth_m": 0.4}, } # 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다. SECTION_GROUND_TYPE_PRESET = { "soil": "soil", "ripping_rock": "rock", "blasting_rock": "rock", } # 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") SECTION_DITCH_SIDES = ("left", "right") # ───────────────────────────────────────────────────────────────────────── # 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) # # 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준. # 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어 # 등급은 grade_to_design_speed로 간접 매핑한다. # 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 # 서로 혼용하지 않는다. # ───────────────────────────────────────────────────────────────────────── FOREST_ROAD_PROFILE_CRITERIA = { # 설계속도(km/h)별 법정 기준 "design_speed": { 40: { "max_grade_pct": {"normal": 7.0, "special": 10.0}, "max_reverse_grade_pct": 5.0, "min_vertical_radius_m": 450.0, "min_curve_length_m": 40.0, }, 30: { "max_grade_pct": {"normal": 8.0, "special": 12.0}, "max_reverse_grade_pct": 5.0, "min_vertical_radius_m": 250.0, "min_curve_length_m": 30.0, }, 20: { "max_grade_pct": {"normal": 9.0, "special": 14.0}, "max_reverse_grade_pct": 5.0, "min_vertical_radius_m": 100.0, "min_curve_length_m": 20.0, }, }, # 임도등급 → 기본 설계속도. 작업임도는 별표에 종단 기준이 없어 20km/h를 준용한다. "grade_to_design_speed": {"trunk": 40, "branch": 30, "work": 20}, # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 "paved_exception_grade_pct": 18.0, # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 "vertical_curve_skip_delta_pct": 5.0, # 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값) "min_tangent_length_m": 20.0, # 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다) "balance_segment_length_m": None, # 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면 # 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다. # V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다. "main_direction_monotone_ratio": 0.5, } GRADE_TERRAIN_TYPES = ("normal", "special") # 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용) GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") # ───────────────────────────────────────────────────────────────────────── # 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2) # # 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을 # "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로 # 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다. # # 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로 # 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다. # ───────────────────────────────────────────────────────────────────────── FOREST_ROAD_PROFILE_ALIGNMENT = { # 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%) # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. "balance_tolerance_percent": 10.0, # 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치). # R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다). # L = R × |기울기 대수차 A(비율)| # 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로 # 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다 # (예: 20.0 → R=400m, A=10%일 때 L=40m). "curve_radius_ratio": 0.40, # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. "curve_tangent_max_ratio": 0.45, # 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지. # 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가 # 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고 # 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다). "curve_skip_legal_exception": False, # 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²). # 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다. "pvi_penalty_m2": 25.0, # 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지) "min_segment_stations": 2, # 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량. "edit_step_m": 0.1, # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) "grade_violation_policy": "warn", } # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 경로 # ───────────────────────────────────────────────────────────────────────── STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "storage") # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log") RESOURCE_LOG_PATH = os.path.join(LOG_BASE_DIR, "system_resources.log") RESOURCE_SAMPLE_INTERVAL_SEC = 120 # 계측 주기: 2분 RESOURCE_LOG_RETENTION_DAYS = 30 # 로그 보관: 1개월 PROJECT_STORAGE_STAGE_DIRS = frozenset( { "B03_FileInput", "B04_wf1_Surface", "B05_wf2_Route", "B06_wf3_ProfileCross", "B07_wf4_DesignDetail", "B08_wf5_Quantity", "B09_wf6_Estimation", } ) # 프로젝트별 저장 구조: storage/[고객사]/[사용자]/[프로젝트ID]/ def get_project_storage_path(company: str, user: str, project_id: str) -> str: """검증된 식별자로 프로젝트 저장 경로를 생성한다.""" segments = (company, user, project_id) for segment in segments: if ( not segment or segment in {".", ".."} or os.path.isabs(segment) or "/" in segment or "\\" in segment ): raise ValueError("저장 경로 식별자는 비어 있거나 경로 구분자를 포함할 수 없습니다.") storage_root = os.path.abspath(STORAGE_BASE_DIR) path = os.path.abspath(os.path.join(storage_root, *segments)) if os.path.commonpath((storage_root, path)) != storage_root: raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.") os.makedirs(path, exist_ok=True) return path # ───────────────────────────────────────────────────────────────────────── # 7. 인증 및 이메일 # ───────────────────────────────────────────────────────────────────────── SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "session_id") DEVICE_TOKEN_COOKIE_NAME = os.getenv("DEVICE_TOKEN_COOKIE_NAME", "device_token") SESSION_MAX_AGE_SECONDS = int(os.getenv("SESSION_MAX_AGE_SECONDS", "43200")) SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv("SESSION_IDLE_TIMEOUT_SECONDS", "14400")) SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true" PASSWORD_BCRYPT_ROUNDS = int(os.getenv("PASSWORD_BCRYPT_ROUNDS", "12")) OTP_VALID_MINUTES = int(os.getenv("EMAIL_OTP_VALID_MINUTES", "5")) EMAIL_REVERIFY_DAYS = int(os.getenv("EMAIL_REVERIFY_DAYS", "90")) SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "True").lower() == "true" EMAIL_FROM_ADDRESS = os.getenv("EMAIL_FROM_ADDRESS", SMTP_USERNAME) EMAIL_FROM_NAME = os.getenv("EMAIL_FROM_NAME", "Aislo Support") EMAIL_RETRY_COUNT = int(os.getenv("EMAIL_RETRY_COUNT", "3")) EMAIL_RETRY_DELAY_SECONDS = int(os.getenv("EMAIL_RETRY_DELAY_SECONDS", "2")) EMAIL_NOTIFICATION_ENABLED = os.getenv("EMAIL_NOTIFICATION_ENABLED", "True").lower() == "true" ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") APP_PUBLIC_BASE_URL = os.getenv( "APP_PUBLIC_BASE_URL", os.getenv("AISLO_BASE_URL", "http://localhost:8000"), ).rstrip("/") # ───────────────────────────────────────────────────────────────────────── # 8. CORS (프론트엔드 도메인) # ───────────────────────────────────────────────────────────────────────── CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:8000").split(",") # ───────────────────────────────────────────────────────────────────────── # 9. 환경 정보 # ───────────────────────────────────────────────────────────────────────── ENVIRONMENT = os.getenv("ENVIRONMENT", "development") # development, staging, production