goheung/app/api/drought.py
2026-02-02 19:07:53 +09:00

568 lines
17 KiB
Python

import io
import os
import glob
import base64
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
import geopandas as gpd
from flask import Blueprint, jsonify, request, current_app
bp = Blueprint('drought', __name__, url_prefix='/api/drought')
# SHP 렌더링 임시 JPG 경로
_SHP_JPG_DIR = None
def _get_shp_jpg_dir():
global _SHP_JPG_DIR
if _SHP_JPG_DIR is None:
_SHP_JPG_DIR = os.path.join(current_app.static_folder, 'drought', 'tmp')
os.makedirs(_SHP_JPG_DIR, exist_ok=True)
return _SHP_JPG_DIR
def _cleanup_old_jpg():
"""이전 렌더링된 JPG 파일 삭제"""
jpg_dir = _get_shp_jpg_dir()
for f in glob.glob(os.path.join(jpg_dir, 'drought_shp_*.jpg')):
try:
os.remove(f)
except OSError:
pass
def _point_to_grade(point):
"""point 값(0~1)을 4등급으로 변환"""
if point <= 0.25:
return '관심'
elif point <= 0.50:
return '주의'
elif point <= 0.75:
return '경계'
else:
return '심각'
# 등급별 색상 (관심→심각: 초록→빨강)
GRADE_COLORS = {
'관심': '#2ecc71',
'주의': '#f1c40f',
'경계': '#e67e22',
'심각': '#e74c3c',
}
def render_shp_to_base64(grid_size=500):
"""SHP + XLSX 매칭하여 point 등급별 색상으로 격자 렌더링. 이전 JPG는 삭제."""
import time
import pandas as pd
from matplotlib.patches import Patch
_cleanup_old_jpg()
base_dir = os.path.join(current_app.static_folder, 'drought', str(grid_size))
shp_path = os.path.join(base_dir, f'goheung_{grid_size}m_merge.shp')
xlsx_path = os.path.join(base_dir, f'goheung_{grid_size}m_center_lon_lat.xlsx')
if not os.path.exists(shp_path):
return None
gdf = gpd.read_file(shp_path)
# XLSX에서 point 값 읽어서 GRID_CD로 매칭
if os.path.exists(xlsx_path):
df = pd.read_excel(xlsx_path)
point_map = dict(zip(df['GRID_CD'], df['point']))
gdf['point'] = gdf['GRID_CD'].map(point_map).fillna(0.0)
else:
gdf['point'] = 0.0
# point → 등급 → 색상
gdf['grade'] = gdf['point'].apply(_point_to_grade)
gdf['color'] = gdf['grade'].map(GRADE_COLORS)
fig, ax = plt.subplots(figsize=(10, 10))
# 등급별로 그려서 범례 생성
for grade, color in GRADE_COLORS.items():
subset = gdf[gdf['grade'] == grade]
if not subset.empty:
subset.plot(ax=ax, color=color, edgecolor='#555', linewidth=0.3, alpha=0.8)
# 범례
legend_handles = [Patch(facecolor=c, edgecolor='#555', label=g) for g, c in GRADE_COLORS.items()]
ax.legend(handles=legend_handles, loc='lower right', fontsize=11, title='가뭄 등급', title_fontsize=12)
ax.set_axis_off()
plt.tight_layout(pad=0)
# JPG 파일로 저장
jpg_dir = _get_shp_jpg_dir()
jpg_name = f'drought_shp_{int(time.time() * 1000)}.jpg'
jpg_path = os.path.join(jpg_dir, jpg_name)
fig.savefig(jpg_path, format='jpg', dpi=150, bbox_inches='tight', pad_inches=0)
# base64 반환
buf = io.BytesIO()
fig.savefig(buf, format='jpg', dpi=150, bbox_inches='tight', pad_inches=0)
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
@bp.route('/shp-render', methods=['GET'])
def shp_render():
"""SHP 파일을 JPG로 렌더링하여 반환"""
grid_size = request.args.get('grid', 500, type=int)
try:
image = render_shp_to_base64(grid_size)
if image is None:
return jsonify({'status': 'error', 'message': 'SHP 파일을 찾을 수 없습니다.'})
return jsonify({'status': 'success', 'image': image})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
def generate_plot():
"""가뭄 예측 샘플 플롯 생성"""
fig, ax = plt.subplots(figsize=(10, 6))
# 샘플 데이터 (실제 데이터로 교체 필요)
np.random.seed(456)
months = ['1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월']
drought_index = np.random.uniform(-2, 2, 12)
colors = ['brown' if x < 0 else 'green' for x in drought_index]
ax.bar(months, drought_index, color=colors, alpha=0.7)
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax.set_title('가뭄 예측 결과 (월별 가뭄 지수)', fontsize=14)
ax.set_xlabel('')
ax.set_ylabel('가뭄 지수')
ax.grid(True, alpha=0.3, axis='y')
# 이미지를 base64로 변환
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
@bp.route('/predict', methods=['GET'])
def predict():
"""가뭄 예측 결과 반환"""
try:
image = generate_plot()
return jsonify({
'status': 'success',
'message': '가뭄 예측 완료',
'image': image
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e),
'image': None
})
def generate_simulation_plot():
"""가뭄 시뮬레이션 플롯 생성"""
fig, ax = plt.subplots(figsize=(10, 6))
np.random.seed(333)
weeks = np.arange(1, 53)
drought_severity = np.sin(weeks / 10) * 1.5 + np.random.normal(0, 0.2, 52)
colors = ['brown' if x < 0 else 'forestgreen' for x in drought_severity]
ax.bar(weeks, drought_severity, color=colors, alpha=0.7, width=0.8)
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax.axhline(y=-1, color='red', linestyle='--', label='심각 가뭄')
ax.set_title('연간 가뭄 시뮬레이션', fontsize=14)
ax.set_xlabel('주차')
ax.set_ylabel('가뭄 심각도')
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
def generate_waterlevel_plot():
"""가뭄 수위 곡선 플롯 생성"""
fig, ax = plt.subplots(figsize=(10, 6))
np.random.seed(444)
days = np.arange(1, 91)
reservoir_level = 80 - np.cumsum(np.abs(np.random.randn(90)) * 0.3)
reservoir_level = np.maximum(reservoir_level, 20)
ax.plot(days, reservoir_level, 'b-', linewidth=2)
ax.fill_between(days, 0, reservoir_level, alpha=0.3, color='blue')
ax.axhline(y=40, color='orange', linestyle='--', label='경고 수위')
ax.axhline(y=25, color='red', linestyle='--', label='위험 수위')
ax.set_title('저수지 수위 변화', fontsize=14)
ax.set_xlabel('')
ax.set_ylabel('저수율 (%)')
ax.set_ylim(0, 100)
ax.legend()
ax.grid(True, alpha=0.3)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
@bp.route('/simulation', methods=['GET'])
def simulation():
"""가뭄 시뮬레이션 결과 반환"""
try:
image = generate_simulation_plot()
return jsonify({
'status': 'success',
'message': '가뭄 시뮬레이션 완료',
'image': image
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e),
'image': None
})
@bp.route('/waterlevel', methods=['GET'])
def waterlevel():
"""가뭄 수위 곡선 결과 반환"""
try:
image = generate_waterlevel_plot()
return jsonify({
'status': 'success',
'message': '수위 곡선 조회 완료',
'image': image
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e),
'image': None
})
def generate_heatmap_image(size=(400, 300)):
"""가뭄 히트맵 이미지 생성"""
fig, ax = plt.subplots(figsize=(size[0]/100, size[1]/100))
np.random.seed(72)
# 히트맵 데이터 생성 (가뭄 위험도)
x = np.linspace(0, 10, 100)
y = np.linspace(0, 8, 80)
X, Y = np.meshgrid(x, y)
# 다중 가우시안으로 히트맵 생성
Z = np.zeros_like(X)
# 중심 핫스팟 (심각 지역)
Z += 0.9 * np.exp(-((X-5)**2 + (Y-4)**2) / 3)
# 주변 핫스팟들
Z += 0.6 * np.exp(-((X-3)**2 + (Y-2)**2) / 2)
Z += 0.7 * np.exp(-((X-7)**2 + (Y-5)**2) / 2.5)
Z += 0.4 * np.exp(-((X-2)**2 + (Y-6)**2) / 1.5)
# 노이즈 추가
Z += np.random.rand(80, 100) * 0.1
# 커스텀 컬러맵 (녹색 -> 노랑 -> 주황 -> 빨강)
from matplotlib.colors import LinearSegmentedColormap
colors = ['#27ae60', '#f1c40f', '#e67e22', '#e74c3c']
cmap = LinearSegmentedColormap.from_list('drought', colors)
ax.imshow(Z, cmap=cmap, aspect='auto', alpha=0.8)
ax.axis('off')
plt.tight_layout(pad=0)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', pad_inches=0, transparent=True)
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
def generate_mini_heatmap_image():
"""미니 히트맵 이미지 생성"""
fig, ax = plt.subplots(figsize=(2.6, 1.2))
np.random.seed(72)
x = np.linspace(0, 5, 50)
y = np.linspace(0, 3, 30)
X, Y = np.meshgrid(x, y)
Z = 0.8 * np.exp(-((X-2.5)**2 + (Y-1.5)**2) / 1.5)
Z += np.random.rand(30, 50) * 0.15
from matplotlib.colors import LinearSegmentedColormap
colors = ['#27ae60', '#f1c40f', '#e67e22', '#e74c3c']
cmap = LinearSegmentedColormap.from_list('drought', colors)
ax.imshow(Z, cmap=cmap, aspect='auto', alpha=0.85)
ax.axis('off')
plt.tight_layout(pad=0)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', pad_inches=0, transparent=True)
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
def generate_monthly_drought_chart():
"""월별 가뭄 지수 차트 생성"""
fig, ax = plt.subplots(figsize=(4, 1.2))
np.random.seed(2025)
months = np.arange(1, 21)
# 2025년 데이터
drought_2025 = 25 + np.sin(months / 3) * 8 + np.random.rand(20) * 5
# 2024년 데이터
drought_2024 = 30 + np.sin(months / 3 + 0.5) * 6 + np.random.rand(20) * 4
ax.plot(months, drought_2025, 'b-', linewidth=1.5, label='2025')
ax.plot(months, drought_2024, color='#e67e22', linewidth=1.5, linestyle='--', label='2024')
ax.fill_between(months, drought_2025, alpha=0.2, color='blue')
ax.set_xlim(1, 20)
ax.set_ylim(15, 45)
ax.set_xticks([1, 8, 9, 12, 15, 20])
ax.set_xticklabels(['1', '8', '9', '12', '15', '20'], fontsize=7)
ax.tick_params(axis='y', labelsize=7)
ax.grid(True, alpha=0.3)
plt.tight_layout(pad=0.5)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight', transparent=True)
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
return image_base64
@bp.route('/monitoring-heatmap', methods=['GET'])
def monitoring_heatmap():
"""가뭄 모니터링 히트맵 이미지 반환"""
try:
heatmap_image = generate_heatmap_image()
mini_heatmap_image = generate_mini_heatmap_image()
return jsonify({
'status': 'success',
'heatmap_image': heatmap_image,
'mini_heatmap_image': mini_heatmap_image,
'risk_score': 72,
'risk_level': '심각'
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e)
})
@bp.route('/monthly-chart', methods=['GET'])
def monthly_chart():
"""월별 가뭄 지수 차트 반환"""
try:
image = generate_monthly_drought_chart()
return jsonify({
'status': 'success',
'image': image
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e),
'image': None
})
def generate_spi_chart(period='month'):
"""SPI (표준강수지수) 시계열 그래프 생성"""
fig, ax = plt.subplots(figsize=(10, 5))
np.random.seed(2025)
if period == 'month':
days = np.arange(1, 31)
xlabel = ''
title = '최근 1개월 SPI 변화'
elif period == 'quarter':
days = np.arange(1, 91)
xlabel = ''
title = '최근 3개월 SPI 변화'
else: # year
days = np.arange(1, 366)
xlabel = ''
title = '최근 1년 SPI 변화'
# SPI 값 생성 (-3 ~ 3 범위)
spi_values = np.sin(days / 15) * 1.2 + np.random.normal(0, 0.3, len(days))
spi_values = np.clip(spi_values, -3, 3)
# 색상별 영역 표시
ax.axhspan(-3, -2, alpha=0.2, color='darkred', label='극심한 가뭄')
ax.axhspan(-2, -1.5, alpha=0.2, color='red', label='심한 가뭄')
ax.axhspan(-1.5, -1, alpha=0.2, color='orange', label='중간 가뭄')
ax.axhspan(-1, 0, alpha=0.2, color='yellow', label='약한 가뭄')
ax.axhspan(0, 3, alpha=0.1, color='green', label='정상/습윤')
ax.plot(days, spi_values, 'b-', linewidth=1.5)
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax.set_title(title, fontsize=14)
ax.set_xlabel(xlabel)
ax.set_ylabel('SPI 값')
ax.set_ylim(-3, 3)
ax.legend(loc='upper right', fontsize=8)
ax.grid(True, alpha=0.3)
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
# 통계 계산
current = round(spi_values[-1], 2)
avg = round(np.mean(spi_values), 2)
min_val = round(np.min(spi_values), 2)
# 가뭄 등급 결정
if current <= -2:
grade = '극심한 가뭄'
elif current <= -1.5:
grade = '심한 가뭄'
elif current <= -1:
grade = '중간 가뭄'
elif current <= 0:
grade = '약한 가뭄'
else:
grade = '정상'
return image_base64, {'current': current, 'avg': avg, 'min': min_val, 'grade': grade}
def generate_vhi_chart(period='month'):
"""VHI (식생건강지수) 시계열 그래프 생성"""
fig, ax = plt.subplots(figsize=(10, 5))
np.random.seed(2026)
if period == 'month':
days = np.arange(1, 31)
xlabel = ''
title = '최근 1개월 VHI 변화'
elif period == 'quarter':
days = np.arange(1, 91)
xlabel = ''
title = '최근 3개월 VHI 변화'
else: # year
days = np.arange(1, 366)
xlabel = ''
title = '최근 1년 VHI 변화'
# VHI 값 생성 (0 ~ 100 범위)
vhi_values = 50 + np.sin(days / 20) * 25 + np.random.normal(0, 5, len(days))
vhi_values = np.clip(vhi_values, 0, 100)
# 색상별 영역 표시
ax.axhspan(0, 10, alpha=0.2, color='darkred', label='극심한 가뭄')
ax.axhspan(10, 20, alpha=0.2, color='red', label='심한 가뭄')
ax.axhspan(20, 30, alpha=0.2, color='orange', label='중간 가뭄')
ax.axhspan(30, 40, alpha=0.2, color='yellow', label='약한 가뭄')
ax.axhspan(40, 100, alpha=0.1, color='green', label='정상')
ax.plot(days, vhi_values, 'g-', linewidth=1.5)
ax.set_title(title, fontsize=14)
ax.set_xlabel(xlabel)
ax.set_ylabel('VHI 값')
ax.set_ylim(0, 100)
ax.legend(loc='upper right', fontsize=8)
ax.grid(True, alpha=0.3)
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
buf.seek(0)
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
plt.close(fig)
# 통계 계산
current = round(vhi_values[-1], 1)
avg = round(np.mean(vhi_values), 1)
min_val = round(np.min(vhi_values), 1)
# 가뭄 등급 결정
if current <= 10:
grade = '극심한 가뭄'
elif current <= 20:
grade = '심한 가뭄'
elif current <= 30:
grade = '중간 가뭄'
elif current <= 40:
grade = '약한 가뭄'
else:
grade = '정상'
return image_base64, {'current': current, 'avg': avg, 'min': min_val, 'grade': grade}
@bp.route('/graph', methods=['GET'])
def drought_graph():
"""가뭄 지표 그래프 반환"""
from flask import request
graph_type = request.args.get('type', 'spi')
period = request.args.get('period', 'month')
try:
if graph_type == 'spi':
image, stats = generate_spi_chart(period)
else: # vhi
image, stats = generate_vhi_chart(period)
return jsonify({
'status': 'success',
'image': image,
'stats': stats
})
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e),
'image': None
})