Files
Aislo/config/config_system.py
T
eomsangdon 9fc4462d74 feat(B04): 도엽 보간에 TIN(도엽선)을 넣고 안 쓰는 3종을 뺀다
사용자 지시(2026-08-30): IDW·거리비례·TIN 곡면 제거, 5m 도엽등고선 기준 TIN 추가.

TIN(도엽선)은 격자에 구운 라인 셀이 아니라 벡터 등고선 정점의 원좌표를 그대로
Delaunay로 잇는다. 격자 밖은 100m 여유만 물고 자르며, 길이 필터는 두지 않는다
(짧은 봉우리 폐합 링을 버리면 마루가 통째로 평평해진다).

거리비례는 버튼에서만 빼고 함수는 남긴다 — 라플라스·TPS·ANUDEM·다중해상도가
초기추정으로 계속 쓴다. 기본 방식은 distance가 빠져 multires로 옮겼다.

검증: 실데이터 767×840·1m 6종 93.5s. 서피스에서 다시 뽑은 5m 선의 평면 이탈이
TIN(도엽선) 중앙 0.12m·2m초과 8.1%로 6종 중 최소(직전 최선 다중해상도
0.34m/18.3%). pytest 8 passed(격자와 어긋난 링 위 표고 재현 테스트 추가).
2026-08-30 17:36:17 +09:00

855 lines
56 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"
# ═════════════════════════════════════════════════════════════════════════
# ⚠ 0. 위험 스위치 — 프로젝트 하드 삭제
# ═════════════════════════════════════════════════════════════════════════
# 대시보드의 프로젝트 삭제 버튼이 무엇을 지우는지 결정한다. 눈에 띄라고 맨 위에 둔다.
#
# False (배포 기본값)
# 지금까지의 동작. `projects.deleted_at`만 찍어 목록에서 감춘다. 사용자가 올린
# 라이다 원본은 나중에 다른 프로젝트 개발에 재활용할 수 있는 자산이라 남긴다.
#
# True (개발 PC 전용)
# `projects` 행을 실제로 DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다),
# 영구저장소의 `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 통째로 지운다.
# B03 업로드 원본까지 없어진다. 되돌릴 수 없다.
#
# 개발 중에는 프로젝트를 반복 생성·삭제하는데 소프트 삭제만 하면 수십 GB 라이다 원본이
# 디스크에 계속 쌓인다. 정리 잡도 없다. 그래서 개발 PC의 `.env`에서만 True로 켠다
# (2026-08-08 사용자 지시).
#
# .env → PROJECT_DELETE_HARD_ENABLED=True
#
# 기본값이 False라 환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다.
PROJECT_DELETE_HARD_ENABLED = os.getenv("PROJECT_DELETE_HARD_ENABLED", "False").lower() == "true"
# ─────────────────────────────────────────────────────────────────────────
# 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"))
# 프로젝트 생성 전 임시 보관함 — 계정에 묶어 자료를 먼저 올려 두는 공간.
# 라이다 원본이 수십 GB라 보관 기간이 길면 디스크를 금방 채운다. 보관 기간과 정리 주기는
# 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시).
TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp")
TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30"))
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6"))
MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600"))
SEND_ANALYSIS_COMPLETION_EMAIL = (
os.getenv("SEND_ANALYSIS_COMPLETION_EMAIL", "True").lower() == "true"
)
# WF1 자동 확정 후 B05 기본 경로 → B06 기본 횡단 설계까지 서버가 이어서 계산·확정한다
# (2026-08-04 사용자 확정). 프로젝트에 경로가 이미 있으면 체인은 스스로 건너뛴다.
AUTO_DESIGN_CHAIN_ENABLED = os.getenv("AUTO_DESIGN_CHAIN_ENABLED", "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"))
# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정)
# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값).
SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0"))
# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값).
SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0"))
# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다
# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py.
SHEET_SURFACE_METHODS = [
method.strip()
for method in os.getenv(
"SHEET_SURFACE_METHODS",
"tin_sheet,tin,biharmonic,anudem,multires,laplace",
).split(",")
if method.strip()
]
# 확정에 쓸 기본 방식 — 비교 후 사용자가 정한다.
SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "multires")
# 일반 사용자 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"))
# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존
# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19).
ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch")
# 임도 등급별 종단경사 한계 및 최소 곡선반지름 (경로탐색 제약 — 계획선 법정 기준과 별개)
FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40}
FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 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_PreProcess/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_Profile_Engine_Watershed_Export.STAGES가 유일한 정의처다.
# ── 확률강우량 (map.wamis.go.kr 등우선도 — 제주 전용) ──
# 등우선 서버 원본은 제주도만 커버한다(2026-08-13 실측) — 제주 공사지에서만 쓴다.
# 근거·검증: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 5절, PLAN.md A.
WAMIS_CONTOUR_CACHE_DIR = PROJECT_ROOT / "resources" / "data_rainfall_idf_cache"
# 프로젝트별 결과 파일명. drainage/ 아래에 놓인다.
DRAINAGE_RAINFALL_FILENAME = "rainfall_table.json"
# ── 확률강우량 (관측소 방식 — 본토, 2026-08-13 사용자 확정 로직) ──
# 최근접 관측소 + 이 반경 안 관측소를 모두 받아 100년/24hr 최대 지점을 선정한다.
WAMIS_STATION_RADIUS_M = float(os.getenv("WAMIS_STATION_RADIUS_M", "10000"))
# 관측소 목록·확률강우량 xlsx의 프로젝트별 보존 폴더명. drainage/ 아래에 놓인다
# (원천이 갱신되는 자료라 프로젝트 시점 스냅샷 — 전역 공통 캐시 아님).
DRAINAGE_RAINFALL_STATION_DIRNAME = "rainfall_stations"
# 설계빈도 강우강도 산출 방식: "mononobe"(기본, 실무 수리계산서 방식) | "general"
DRAINAGE_RAINFALL_IDF_METHOD = os.getenv("DRAINAGE_RAINFALL_IDF_METHOD", "mononobe")
# ── 배수 유효직경 계산 계수 (2026-08-05 사용자 확정, 위 규정 조사 문서 6절) ──
# 합리식 유출계수 C. 산악지 준용 0.8 — 필요 시 사용자가 이 값을 고친다.
DRAINAGE_RUNOFF_COEFFICIENT = float(os.getenv("DRAINAGE_RUNOFF_COEFFICIENT", "0.8"))
# 설계유량 배수. 시행규칙 별표2 (가): 최대홍수유출량의 2.0배 이상.
DRAINAGE_DESIGN_FLOW_FACTOR = float(os.getenv("DRAINAGE_DESIGN_FLOW_FACTOR", "2.0"))
# 설계빈도(년). 별표2 (가) 1호: 100년빈도 확률강우량.
DRAINAGE_DESIGN_RETURN_PERIOD_YR = int(os.getenv("DRAINAGE_DESIGN_RETURN_PERIOD_YR", "100"))
# 홍수도달시간 하한(분). 국도건설공사 설계실무요령·도로배수지침 "강우지속기간 5분 원칙".
DRAINAGE_TC_MIN_MINUTES = float(os.getenv("DRAINAGE_TC_MIN_MINUTES", "5.0"))
# 관 경사(도). 시공 중 수시 변경되므로 지형 산출 대신 실무 대푯값 10도로 고정(사용자 제시).
DRAINAGE_PIPE_SLOPE_DEG = float(os.getenv("DRAINAGE_PIPE_SLOPE_DEG", "10.0"))
# Manning 조도계수(파형강관).
DRAINAGE_MANNING_N = float(os.getenv("DRAINAGE_MANNING_N", "0.024"))
# 관내 유속 허용범위(m/s). 도로배수지침 0.8~3.0 — 상한 클램프가 안전측(관이 커진다).
DRAINAGE_VELOCITY_MIN_MS = float(os.getenv("DRAINAGE_VELOCITY_MIN_MS", "0.8"))
DRAINAGE_VELOCITY_MAX_MS = float(os.getenv("DRAINAGE_VELOCITY_MAX_MS", "3.0"))
# 원형관 통수단면 비율. 도로설계요령: 관 단면의 70%만 통수 고려.
DRAINAGE_FLOW_AREA_RATIO = float(os.getenv("DRAINAGE_FLOW_AREA_RATIO", "0.7"))
# 세월교 검토 문턱(mm). 유효직경이 관 최대 규격(파형강관 D2000)을 넘으면 관이 아니라
# 세월교·물넘이·교량 대상이다 — 「임도설치 및 관리 등에 관한 규정」 제12조: 계류 횡단
# 구간은 배수구 막힘 우려가 없는 물넘이 포장(세월교) 또는 교량으로 설계(2026-08-05 조사).
DRAINAGE_BRIDGE_THRESHOLD_MM = float(os.getenv("DRAINAGE_BRIDGE_THRESHOLD_MM", "2000"))
# BOX암거 전환 문턱(mm). 교본 "수리계산 Ø1,500㎜ 이상 유역·협곡·횡단경사 40% 이내"에서
# 유량 조건만 취한 값이다 — 횡단경사·협곡 판정은 지형 계산이 필요해 화면이 "현장 확인"으로
# 안내한다 (2026-08-17 사용자 확정: 추천은 유량 근거만).
DRAINAGE_BOX_THRESHOLD_MM = float(os.getenv("DRAINAGE_BOX_THRESHOLD_MM", "1500"))
# 추천 관경 규격(mm) — B05 레지스트리 `pipe_diameter_mm` 선택지와 **같아야 한다**.
# 폼에서 고를 수 없는 값을 추천하면 사용자가 그대로 확정하지 못한다.
DRAINAGE_RECOMMEND_DIAMETERS_MM = (800, 1000, 1200, 1500)
# ── 물넘이포장·세월교 개략 단면 (2026-08-17 실무문서 역산 확인) ──
# 물넘이는 노면 개수로다. 조도계수 0.017은 KDS 표 2.7-1 콘크리트 수로 "보통"값이자
# 실무 물넘이 관측치와 일치하고, 경사 10%는 실무 수리계산서 역산값이다 — 울진1공구
# 물넘이 시트(B=20m·h=0.27m·n=0.017 → V=7.636 m/s)와 같은 공사지 관 시트(Ø1000·240°
# 유효단면·n=0.025 → V=5.694 m/s)가 둘 다 S=0.10에서 검산이 맞는다
# (original/실무문서/_숨김탭분석.md §6-1·§6-2).
FORD_MANNING_N = float(os.getenv("FORD_MANNING_N", "0.017"))
FORD_SLOPE = float(os.getenv("FORD_SLOPE", "0.10"))
# ── B05 구조물 옵션 (드롭다운 목록·기본값, 2026-08-05 사용자 확정) ──
# 프론트는 이 값을 /api 설정 응답 또는 빌드타임 복사로 받아 쓴다. 수정은 여기서만 한다.
STRUCTURE_TYPES = ("배관", "기성막이", "대피로", "기타")
STRUCTURE_DEFAULT_TYPE = "배관"
PIPE_DIAMETERS_MM = {
"이중벽관": (150, 200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500),
"삼중벽관": (200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500),
"파형강관": (
150,
200,
250,
300,
350,
400,
450,
500,
600,
700,
800,
900,
1000,
1200,
1350,
1500,
1650,
1800,
2000,
),
}
PIPE_DEFAULT_TYPE = "파형강관"
# 자동 지정 기본 관경. 별표2 (나) 예외 하한 800mm(사용자 결정 — 계산값이 더 크면 바로 위 규격).
PIPE_DEFAULT_DIAMETER_MM = int(os.getenv("PIPE_DEFAULT_DIAMETER_MM", "800"))
ESCAPE_ROUTE_WIDTHS_M = (1.5, 2.0, 2.5, 3.0)
ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0
STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물"
# ─────────────────────────────────────────────────────────────────────────
# 5-4. 종횡단 생성 파라미터 (B06 WF3)
# ─────────────────────────────────────────────────────────────────────────
SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0"))
# 초기 파이프라인(파일입력→B06) 샘플 반폭 — 표시 반폭보다 넉넉히 뽑아 두면 사용자가
# 표시 반폭을 이 안에서 바꿀 때 재계산이 필요 없다(2026-08-06 사용자 확정, 기준 20m).
# 표시 반폭이 이 값을 넘을 때만 B05부터 재생성한다. 향후 사용자 평균 설정 보고 조정.
SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "20.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_Section_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,
},
},
# 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
# 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서
# 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른
# 설계속도가 오면 그 값이 우선한다(design_speed_kph).
# trunk = 간선임도 / fire = 산불진화임도 / work = 작업임도
# branch(지선)는 현행 규칙에서 폐지 — 기존 데이터 호환용으로만 남긴다.
"grade_to_design_speed": {"trunk": 20, "fire": 20, "work": 20, "branch": 20},
# 화면에서 고를 수 있는 설계속도 — 작업임도는 20만 허용된다(별표2 Ⅰ.3).
"selectable_design_speeds": {"trunk": (20, 30, 40), "fire": (20, 30, 40), "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,
# **기본 종단곡선 길이 L(m) — 모든 변화점의 1차 기준값.**
# 임도 계획선은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면
# L = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다
# (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다.
# 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다.
# 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만
# 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시).
# 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다.
"default_curve_length_m": 15.0,
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
# 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_PreProcess/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", "")
# ─────────────────────────────────────────────────────────────────────────
# B07 도면 작성 척도 (2026-08-29 사용자 확정)
# 종단면도: 가로 1/1,000 · 세로 1/200 (세로 5배 과장)
# 횡단면도: 가로·세로 1/100
# 도면 좌표는 종이 밀리미터다. 실거리 1 m가 종이에서 몇 mm인지 = 1000 / 축척분모.
# ─────────────────────────────────────────────────────────────────────────
DRAWING_SCALE_LONG_H = 1000 # 종단면도 가로 축척 분모
DRAWING_SCALE_LONG_V = 200 # 종단면도 세로 축척 분모
DRAWING_SCALE_CROSS = 100 # 횡단면도 가로·세로 축척 분모
DRAWING_SHEET = "A1" # 용지 규격 (840x594 mm 도각 템플릿)
# 시스템 리소스 로그 (루트 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_PreProcess",
"B05_Profile",
"B06_Section",
"B07_DesignDetail",
"B08_Quantity",
"B09_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