Files
Aislo/config/config_system.py
T
eomsangdonandClaude Opus 5 f4f56069b0 feat(B05/B06): 유토곡선 횡단 기준 통일·자동선정 삭제·계획선 R 연결 외 9건
사용자 피드백 반영(2026-08-03 2차).

유토곡선 일원화
- B05 유토곡선을 B06과 같은 계산(computeMassHaulSeries: 횡단 정식 + 종단 개략
  비교)으로 전환. 상세 조회가 미지정 측점에 기본 설계를 즉석 계산해 얹으므로
  B05 진입 시점에 이미 정식 곡선 재료가 있다. 표시 토글 sessionStorage 키와
  balloon 위치 scope를 B06과 공유해 두 화면이 같은 그림을 유지한다.
- B05 전용 개략 엔진(computeLongitudinalMassHaul) 삭제.
- 유토곡선 펼침 시 종단 그래프:곡선 세로 1:1 분할.

사토장·토취장 자동 선정 삭제
- 임도에는 토취장이 없고 부족분은 설계자가 계획선을 고쳐 맞춘다는 실무 판단에
  따라 4옵션(비용/지형안정성/계곡부/임내공간) 엔진·API·UI·config·저장 훅 전부
  제거. massHaulPayload/createMassHaulLegend 시그니처 원복.

기본 지반 변경
- 기본 설계 프리뷰·확정 기본값을 토사에서 리핑암 + 예상 암반 경계 0.5m로 변경.
  산지 절토는 표토 아래 암이 일반적이라 전량 토사 가정은 물량이 낙관적이다.
  발파암은 B06에서 측점별 수정.

계획선 R·선 연결 (배관 구조물 자리)
- 계획선 샘플이 고정 격자로만 평가되어 격자 사이 변화점(배관 측점 승격분)의
  모서리를 잘라먹던 결함 수정 — 샘플 집합에 PVI·BVC·EVC를 합집합으로 포함
  (프론트 buildAlignment + 서버 build_alignment 동일 규칙). 면적 가중치도
  합쳐진 격자로 재계산. 37.3m 변화점 + R=150 수치 검증 통과.

표시 개선
- EP(종점) 잔량 라벨: 곡선 끝점에 "EP {누가토량}㎥" 불투명 판(B05·B06 공통).
- 유토곡선 0선을 붉은 굵은 실선(2.5px)으로, 0 눈금값도 적색.
- 3D 뷰 [측점 가로선] 우측 [측점 라벨] 토글 신설(기본 꺼짐) — 구조물 측점만
  "측점번호 구조물명" 스프라이트 표시.

typecheck·vite build·ruff·B03 테스트 통과. 실서버 스모크로 리핑암 기본 프리뷰와
disposal-sites 제거 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:54:37 +09:00

680 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 = [".csv", ".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-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계)
#
# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링)
# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가
# 유일한 판정 기준이며, 그 경계가 곧 능선이다.
# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시).
# ─────────────────────────────────────────────────────────────────────────
# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다.
DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0"))
# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로
# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다.
# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이
# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시).
DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0"))
# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m).
DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0"))
# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀).
DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6"))
# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다.
DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0"))
# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀).
DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20"))
# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은
# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다.
DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000"))
# 해석용 **도로 굽기 폭**(m) — 설계 도로폭이 아니다.
#
# 설계 도로폭은 등급별로 따로 있다(B05 `/sections/road-widths`: 간선 3.0 / 지선 3.0 / 작업로 2.5m).
# 여기 값은 노선을 격자에 구울 때만 쓰는 해석 파라미터다. 1m 격자에서 도로를 1셀 선으로 구우면
# D8 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리므로, 3셀(=3m) 이상 두께가 필요하다.
# 그래서 설계폭과 연동하지 않고 4m로 둔다(2026-08-01 사용자 확인). 값을 바꾸면 재분석해야 한다.
DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0"))
# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다.
DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0"))
# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다.
DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0"))
# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다.
DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0"))
# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망
# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다.
DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0"))
# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다.
DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001"))
# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다.
DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0"))
# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다.
DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0"))
# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m).
DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0"))
# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다.
DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0"))
# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거).
DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0"))
# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_wf1_Surface/drainage/ 아래에 놓인다.
DRAINAGE_CACHE_DIRNAME = "drainage"
DRAINAGE_CACHE_FILENAME = "watershed_grid.npz"
# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 —
# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시).
DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json"
# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다.
DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson"
# ── 관 매설 지점 편집분 ──
# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다.
# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다.
DRAINAGE_EDITS_DIRNAME = "edits"
DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json"
# ── B05용 평균 흐름 화살표 ──
# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다.
DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0"))
# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다.
DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0"))
# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지).
DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5"))
# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다.
DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7"))
# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다.
# 파일명 규칙은 B05_wf2_Route_Engine_Watershed_Export.STAGES가 유일한 정의처다.
# ─────────────────────────────────────────────────────────────────────────
# 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. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정)
#
# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값.
# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장).
# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %.
#
# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과
# 절·성토 단면적 엔진(B06_wf3_ProfileCross_Engine_Design)이 모두 여기를 읽으며,
# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다.
# ─────────────────────────────────────────────────────────────────────────
STANDARD_CROSS_SECTION = {
"soil": {
"road_width_m": 3.0, # 노폭(차도)
"shoulder_left_m": 0.5, # 노견(좌)
"shoulder_right_m": 0.5, # 노견(우)
# 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm.
"ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3},
"cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향)
"fill_slope_ratio": 1.2, # 성토 1:1.2
"cut_slope_ratio": 1.0, # 절토 1:1.0
},
"rock": { # 리핑암·발파암 공유
"road_width_m": 3.0,
"shoulder_left_m": 0.5,
"shoulder_right_m": 0.5,
# 일반 측구 = 690/300/300mm.
"ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3},
# L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택).
"ditch_l_type": {"width_m": 0.5, "depth_m": 0.1},
"cross_slope_pct": {"min": 3.0, "max": 3.0},
"fill_slope_ratio": 1.2,
"cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상)
},
"paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름
"road_width_m": 3.0,
"shoulder_left_m": 0.5,
"shoulder_right_m": 0.5,
"ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3},
"cross_slope_pct": {"min": 1.5, "max": 2.0},
"fill_slope_ratio": 1.2,
"cut_slope_ratio": 1.0,
# 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능).
"pavement_thickness_m": 0.2,
},
}
# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m).
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5
STANDARD_ROCK_BOUNDARY_STEP_M = 0.1
# ─────────────────────────────────────────────────────────────────────────
# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3)
#
# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지).
# 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종
# 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분).
# ─────────────────────────────────────────────────────────────────────────
_SCS_SOIL = STANDARD_CROSS_SECTION["soil"]
# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능.
SECTION_ROADBED_WIDTH_M = float(
os.getenv(
"SECTION_ROADBED_WIDTH_M",
str(
_SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"]
),
)
)
SECTION_CARRIAGEWAY_WIDTH_M = float(
os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"]))
)
# 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정.
SECTION_FILL_SLOPE_RATIO = float(
os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"]))
)
# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다.
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")
# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지).
SECTION_DITCH_TYPES = ("standard", "l_type")
# ─────────────────────────────────────────────────────────────────────────
# 5-4-3. 토량환산계수 (B06 유토곡선)
#
# loose(L) = 흐트러진 상태 토량 / 자연상태 토량 (팽창률)
# compacted(C) = 다져진 상태 토량 / 자연상태 토량 (다짐률)
#
# 유토곡선은 운반계획 도면이므로 **다짐상태 기준**으로 계산한다.
# "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고,
# 내역서에 적용하는 수량은 자연상태로 한다."
# (2021년도 국도건설공사 설계실무 요령 / 2016 건설공사 표준품셈 계열)
#
# 아래 값은 표준품셈 토량환산계수표의 암종별 범위를 B06 지반유형 3종에 대응시킨
# 제안값이며 임도 전용 고시 수치가 아니다.
# 토사 ← 풍화토(L 1.10~1.25 / C 0.80~0.90) ~ 점토(L 1.20~1.35 / C 0.75~0.90)
# 리핑암 ← 풍화암(L 1.30~1.35 / C 1.00~1.15) ~ 연암(L 1.30~1.50 / C 1.00~1.30) 하단
# 발파암 ← 보통암(L 1.55~1.70 / C 1.20~1.40) 중앙
# 표준품셈도 "토질 시험하여 적용하는 것을 원칙으로 하되 소량인 경우 환산계수표에
# 따를 수 있다"고 하므로 현장별 조정이 전제다. 값을 바꾸려면 여기만 고치면 된다.
#
# 이 상수가 **토량환산계수의 유일한 정의처**다(타 파일 중복 정의 금지).
# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 8절
# ─────────────────────────────────────────────────────────────────────────
EARTHWORK_CONVERSION_FACTORS = {
"soil": {"loose": 1.25, "compacted": 0.90},
"ripping_rock": {"loose": 1.35, "compacted": 1.15},
"blasting_rock": {"loose": 1.60, "compacted": 1.30},
}
# ─────────────────────────────────────────────────────────────────────────
# 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획)
#
# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m`
# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다.
#
# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭
#
# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을
# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫,
# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다.
# 현장·발주처 기준에 따라 달라질 수 있으므로
# **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다.
#
# key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프.
# 화면 표기 문구는 하드코딩 금지 규칙에 따라 ui_locales에서 따로 관리한다.
# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
# ─────────────────────────────────────────────────────────────────────────
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = (
("free_haul", 20.0),
("dozer", 70.0),
("dump_truck", None),
)
# ─────────────────────────────────────────────────────────────────────────
# 5-4-2. 자연방토 판정 경사 (B06 유토곡선)
#
# 자연방토 = 남는 흙을 성토사면 아래로 흘려보내 **운반비를 세지 않는** 처리.
# 판정 기준은 **성토측 자연 지반의 경사**다(2026-08-02 사용자 확정). 지반이 이보다 가파르면
# 부어 놓은 흙이 쌓이지 않고 스스로 흘러내리므로 따로 운반하지 않는다.
#
# 값은 rise/run(무차원). 1/1.5 = 0.667 ≈ 33.7°로, 토사 안식각 상한이자 표준 성토 경사 1:1.5와
# 같은 자리다. 현장·발주처 기준에 따라 달라질 수 있으므로 **이 상수가 유일한 정의처**다.
# ─────────────────────────────────────────────────────────────────────────
NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5
# ─────────────────────────────────────────────────────────────────────────
# 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")
# 1:5,000 수치지형도 도엽 저장 폴더명 — 프로젝트 영구저장소 하위
# (storage/{회사}/{사용자}/{프로젝트ID}/B04_wf1_Surface/processed/map_sheets/)
MAP_SHEETS_DIRNAME = "map_sheets"
MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json"
# 배경 지도(위성·하이브리드·백지도) 확보 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽.
# 1:5,000 도엽 1매는 약 2.2km × 2.3km다. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시).
#
# 도엽 1매를 zoom 18로 받으면 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다.
# 아래 한도 안에 들어오는 가장 선명한 zoom을 자동으로 고른다(브이월드 타일 눈금 그대로).
SURFACE_MAP_MAX_TILES_PER_SIDE = 16
SURFACE_MAP_MAX_ZOOM = 18
SURFACE_MAP_MIN_ZOOM = 14
# 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env)
VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "")
VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "")
# 선택: 수동 발급 세션 쿠키 (예: "JSESSIONID=..; SSCSID=.."). 비어 있으면 자동 로그인 사용.
VWORLD_SESSION_COOKIE = os.getenv("VWORLD_SESSION_COOKIE", "")
# 시스템 리소스 로그 (루트 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