Spaces:
Sleeping
Sleeping
SeungHyeok Jang
commited on
Commit
·
613de59
1
Parent(s):
a7d4b8e
modulizatioin
Browse files- api_utils.py +206 -0
- app.py +17 -1151
- chatbot.py +126 -0
- chatbot_utils.py +114 -0
- config.py +20 -0
- prediction.py +300 -0
- supabase_utils.py +126 -0
- ui.py +42 -0
api_utils.py
ADDED
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from datetime import datetime, timedelta
|
2 |
+
import pandas as pd
|
3 |
+
import pytz
|
4 |
+
import plotly.graph_objects as go
|
5 |
+
from plotly.subplots import make_subplots
|
6 |
+
|
7 |
+
from supabase_utils import get_supabase_client
|
8 |
+
from config import STATION_NAMES
|
9 |
+
|
10 |
+
def api_get_current_tide(station_id):
|
11 |
+
"""현재 조위 조회"""
|
12 |
+
supabase = get_supabase_client()
|
13 |
+
if not supabase:
|
14 |
+
return {"error": "Supabase 클라이언트를 생성할 수 없습니다."}
|
15 |
+
|
16 |
+
try:
|
17 |
+
result = supabase.table('tide_predictions') \
|
18 |
+
.select('predicted_at, final_tide_level') \
|
19 |
+
.eq('station_id', station_id) \
|
20 |
+
.order('predicted_at', desc=True) \
|
21 |
+
.limit(1) \
|
22 |
+
.execute()
|
23 |
+
|
24 |
+
if result.data:
|
25 |
+
return result.data[0]
|
26 |
+
else:
|
27 |
+
return {"error": "데이터가 없습니다."}
|
28 |
+
except Exception as e:
|
29 |
+
return {"error": f"데이터 조회 오류: {e}"}
|
30 |
+
|
31 |
+
def api_get_historical_tide(station_id, date_str, hours=24):
|
32 |
+
"""과거 특정 날짜의 조위 데이터 조회"""
|
33 |
+
supabase = get_supabase_client()
|
34 |
+
if not supabase:
|
35 |
+
return {"error": "Supabase 클라이언트를 생성할 수 없습니다."}
|
36 |
+
|
37 |
+
try:
|
38 |
+
start_time = datetime.strptime(date_str, '%Y-%m-%d')
|
39 |
+
start_time_kst = pytz.timezone('Asia/Seoul').localize(start_time)
|
40 |
+
end_time_kst = start_time_kst + timedelta(hours=hours)
|
41 |
+
|
42 |
+
start_utc = start_time_kst.astimezone(pytz.UTC).isoformat()
|
43 |
+
end_utc = end_time_kst.astimezone(pytz.UTC).isoformat()
|
44 |
+
|
45 |
+
result = supabase.table('historical_tide') \
|
46 |
+
.select('observed_at, tide_level') \
|
47 |
+
.eq('station_id', station_id) \
|
48 |
+
.gte('observed_at', start_utc) \
|
49 |
+
.lte('observed_at', end_utc) \
|
50 |
+
.order('observed_at') \
|
51 |
+
.execute()
|
52 |
+
|
53 |
+
if not result.data:
|
54 |
+
return {"error": f"{date_str}에 대한 과거 데이터가 없습니다."}
|
55 |
+
|
56 |
+
df = pd.DataFrame(result.data)
|
57 |
+
df['observed_at'] = pd.to_datetime(df['observed_at']).dt.tz_convert('Asia/Seoul')
|
58 |
+
df['tide_level'] = pd.to_numeric(df['tide_level'])
|
59 |
+
|
60 |
+
fig = go.Figure()
|
61 |
+
fig.add_trace(go.Scatter(x=df['observed_at'], y=df['tide_level'], mode='lines',
|
62 |
+
name=f'{STATION_NAMES.get(station_id, station_id)} 조위'))
|
63 |
+
fig.update_layout(
|
64 |
+
title=f'{STATION_NAMES.get(station_id, station_id)} - {date_str} 조위',
|
65 |
+
xaxis_title='시간',
|
66 |
+
yaxis_title='조위 (cm)',
|
67 |
+
height=400
|
68 |
+
)
|
69 |
+
return fig
|
70 |
+
|
71 |
+
except Exception as e:
|
72 |
+
return {"error": f"데이터 조회 중 오류 발생: {e}"}
|
73 |
+
|
74 |
+
def api_get_historical_extremes(station_id, date_str):
|
75 |
+
"""과거 특정 날짜의 만조/간조 정보"""
|
76 |
+
supabase = get_supabase_client()
|
77 |
+
if not supabase:
|
78 |
+
return {"error": "Supabase 클라이언트를 생성할 수 없습니다."}
|
79 |
+
|
80 |
+
try:
|
81 |
+
start_time = datetime.strptime(date_str, '%Y-%m-%d')
|
82 |
+
start_time_kst = pytz.timezone('Asia/Seoul').localize(start_time)
|
83 |
+
end_time_kst = start_time_kst + timedelta(days=1)
|
84 |
+
|
85 |
+
start_utc = start_time_kst.astimezone(pytz.UTC).isoformat()
|
86 |
+
end_utc = end_time_kst.astimezone(pytz.UTC).isoformat()
|
87 |
+
|
88 |
+
result = supabase.table('historical_tide') \
|
89 |
+
.select('observed_at, tide_level') \
|
90 |
+
.eq('station_id', station_id) \
|
91 |
+
.gte('observed_at', start_utc) \
|
92 |
+
.lte('observed_at', end_utc) \
|
93 |
+
.order('observed_at') \
|
94 |
+
.execute()
|
95 |
+
|
96 |
+
if not result.data or len(result.data) < 3:
|
97 |
+
return {"error": f"{date_str}의 만조/간조를 계산할 데이터가 부족합니다."}
|
98 |
+
|
99 |
+
df = pd.DataFrame(result.data)
|
100 |
+
df['observed_at'] = pd.to_datetime(df['observed_at']).dt.tz_convert('Asia/Seoul')
|
101 |
+
df['tide_level'] = pd.to_numeric(df['tide_level'])
|
102 |
+
|
103 |
+
df['min'] = df.tide_level[(df.tide_level.shift(1) > df.tide_level) & (df.tide_level.shift(-1) > df.tide_level)]
|
104 |
+
df['max'] = df.tide_level[(df.tide_level.shift(1) < df.tide_level) & (df.tide_level.shift(-1) < df.tide_level)]
|
105 |
+
|
106 |
+
extremes_df = df.dropna(subset=['min', 'max'], how='all').copy()
|
107 |
+
extremes_df['type'] = extremes_df.apply(lambda row: '만조' if pd.notna(row['max']) else '간조', axis=1)
|
108 |
+
extremes_df['value'] = extremes_df.apply(lambda row: row['max'] if pd.notna(row['max']) else row['min'], axis=1)
|
109 |
+
extremes_df['time'] = extremes_df['observed_at'].dt.strftime('%H:%M')
|
110 |
+
|
111 |
+
return extremes_df[['time', 'type', 'value']]
|
112 |
+
|
113 |
+
except Exception as e:
|
114 |
+
return {"error": f"데이터 처리 중 오류 발생: {e}"}
|
115 |
+
|
116 |
+
def api_compare_dates(station_id, date1, date2):
|
117 |
+
"""두 날짜의 조위 패턴 비교"""
|
118 |
+
supabase = get_supabase_client()
|
119 |
+
if not supabase:
|
120 |
+
return {"error": "Supabase 클라이언트를 생성할 수 없습니다."}
|
121 |
+
|
122 |
+
def get_data_for_date(target_date):
|
123 |
+
start = pytz.timezone('Asia/Seoul').localize(datetime.strptime(target_date, '%Y-%m-%d'))
|
124 |
+
end = start + timedelta(days=1)
|
125 |
+
res = supabase.table('historical_tide') \
|
126 |
+
.select('observed_at, tide_level') \
|
127 |
+
.eq('station_id', station_id) \
|
128 |
+
.gte('observed_at', start.astimezone(pytz.UTC).isoformat()) \
|
129 |
+
.lte('observed_at', end.astimezone(pytz.UTC).isoformat()) \
|
130 |
+
.order('observed_at') \
|
131 |
+
.execute()
|
132 |
+
return res.data
|
133 |
+
|
134 |
+
data1 = get_data_for_date(date1)
|
135 |
+
data2 = get_data_for_date(date2)
|
136 |
+
|
137 |
+
if not data1 or not data2:
|
138 |
+
return {"error": "두 날짜 중 하나의 데이터가 없습니다."}
|
139 |
+
|
140 |
+
df1 = pd.DataFrame(data1)
|
141 |
+
df1['tide_level'] = pd.to_numeric(df1['tide_level'])
|
142 |
+
df1['minutes_from_start'] = (pd.to_datetime(df1['observed_at']) - pd.to_datetime(df1['observed_at']).iloc[0]).dt.total_seconds() / 60
|
143 |
+
|
144 |
+
df2 = pd.DataFrame(data2)
|
145 |
+
df2['tide_level'] = pd.to_numeric(df2['tide_level'])
|
146 |
+
df2['minutes_from_start'] = (pd.to_datetime(df2['observed_at']) - pd.to_datetime(df2['observed_at']).iloc[0]).dt.total_seconds() / 60
|
147 |
+
|
148 |
+
fig = go.Figure()
|
149 |
+
fig.add_trace(go.Scatter(x=df1['minutes_from_start'], y=df1['tide_level'], mode='lines', name=date1))
|
150 |
+
fig.add_trace(go.Scatter(x=df2['minutes_from_start'], y=df2['tide_level'], mode='lines', name=date2))
|
151 |
+
fig.update_layout(title=f'{STATION_NAMES.get(station_id, station_id)} 조위 비교: {date1} vs {date2}',
|
152 |
+
xaxis_title='자정부터 경과 시간(분)', yaxis_title='조위 (cm)')
|
153 |
+
return fig
|
154 |
+
|
155 |
+
def api_get_monthly_summary(station_id, year, month):
|
156 |
+
"""월간 조위 요약 통계"""
|
157 |
+
supabase = get_supabase_client()
|
158 |
+
if not supabase:
|
159 |
+
return {"error": "Supabase 클라이언트를 생성할 수 없습니다."}
|
160 |
+
|
161 |
+
try:
|
162 |
+
start_date = f"{year}-{int(month):02d}-01"
|
163 |
+
end_date = (datetime.strptime(start_date, '%Y-%m-%d') + pd.offsets.MonthEnd(1)).strftime('%Y-%m-%d')
|
164 |
+
|
165 |
+
start_utc = pytz.timezone('Asia/Seoul').localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(pytz.UTC).isoformat()
|
166 |
+
end_utc = (pytz.timezone('Asia/Seoul').localize(datetime.strptime(end_date, '%Y-%m-%d')) + timedelta(days=1)).astimezone(pytz.UTC).isoformat()
|
167 |
+
|
168 |
+
result = supabase.table('historical_tide') \
|
169 |
+
.select('observed_at, tide_level') \
|
170 |
+
.eq('station_id', station_id) \
|
171 |
+
.gte('observed_at', start_utc) \
|
172 |
+
.lte('observed_at', end_utc) \
|
173 |
+
.order('observed_at') \
|
174 |
+
.execute()
|
175 |
+
|
176 |
+
if not result.data:
|
177 |
+
return {"error": f"{year}년 {month}월 데이터가 없습니다."}
|
178 |
+
|
179 |
+
df = pd.DataFrame(result.data)
|
180 |
+
df['observed_at'] = pd.to_datetime(df['observed_at']).dt.tz_convert('Asia/Seoul')
|
181 |
+
df['tide_level'] = pd.to_numeric(df['tide_level'])
|
182 |
+
|
183 |
+
highest = df.loc[df['tide_level'].idxmax()]
|
184 |
+
lowest = df.loc[df['tide_level'].idxmin()]
|
185 |
+
avg_tide = df['tide_level'].mean()
|
186 |
+
|
187 |
+
df['date'] = df['observed_at'].dt.date
|
188 |
+
daily_range = df.groupby('date')['tide_level'].apply(lambda x: x.max() - x.min())
|
189 |
+
avg_range = daily_range.mean()
|
190 |
+
|
191 |
+
summary = {
|
192 |
+
"최고 조위": f"{highest['tide_level']:.1f}cm ({highest['observed_at'].strftime('%Y-%m-%d %H:%M')})",
|
193 |
+
"최저 조위": f"{lowest['tide_level']:.1f}cm ({lowest['observed_at'].strftime('%Y-%m-%d %H:%M')})",
|
194 |
+
"평균 조위": f"{avg_tide:.1f}cm",
|
195 |
+
"평균 조차": f"{avg_range:.1f}cm"
|
196 |
+
}
|
197 |
+
|
198 |
+
fig = make_subplots(rows=2, cols=1, subplot_titles=("일별 조위 변화", "일별 조차"))
|
199 |
+
fig.add_trace(go.Box(x=df['observed_at'].dt.strftime('%Y-%m-%d'), y=df['tide_level'], name='조위'), row=1, col=1)
|
200 |
+
fig.add_trace(go.Bar(x=daily_range.index.strftime('%Y-%m-%d'), y=daily_range.values, name='조차'), row=2, col=1)
|
201 |
+
fig.update_layout(height=700, title_text=f"{STATION_NAMES.get(station_id, station_id)} - {year}년 {month}월 요약")
|
202 |
+
|
203 |
+
return summary, fig
|
204 |
+
|
205 |
+
except Exception as e:
|
206 |
+
return {"error": f"월간 요약 생성 중 오류 발생: {e}"}, None
|
app.py
CHANGED
@@ -1,1157 +1,23 @@
|
|
1 |
-
import gradio as gr
|
2 |
-
import subprocess
|
3 |
-
import json
|
4 |
-
import os
|
5 |
-
import numpy as np
|
6 |
-
import pandas as pd
|
7 |
-
import plotly.graph_objects as go
|
8 |
-
from plotly.subplots import make_subplots
|
9 |
-
import plotly.express as px
|
10 |
-
from datetime import datetime, timedelta
|
11 |
-
import pytz
|
12 |
-
from dateutil import parser as date_parser
|
13 |
import warnings
|
14 |
-
import
|
15 |
|
16 |
-
#
|
17 |
-
|
18 |
-
from supabase import create_client, Client
|
19 |
-
SUPABASE_AVAILABLE = True
|
20 |
-
except ImportError:
|
21 |
-
SUPABASE_AVAILABLE = False
|
22 |
-
print("Supabase 패키지가 설치되지 않았습니다.")
|
23 |
-
|
24 |
-
# Gemini 연동 확인
|
25 |
-
try:
|
26 |
-
import google.generativeai as genai
|
27 |
-
GEMINI_AVAILABLE = True
|
28 |
-
except ImportError:
|
29 |
-
GEMINI_AVAILABLE = False
|
30 |
-
print("Gemini (google-generativeai) 패키지가 설치되지 않았습니다.")
|
31 |
-
|
32 |
-
warnings.filterwarnings('ignore')
|
33 |
-
|
34 |
-
# --- 0. 설정 ---
|
35 |
-
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
36 |
-
SUPABASE_KEY = os.environ.get("SUPABASE_KEY") # SUPABASE_ANON_KEY를 SUPABASE_KEY로 통일
|
37 |
-
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
38 |
-
|
39 |
-
STATIONS = [
|
40 |
-
"DT_0001", "DT_0065", "DT_0008", "DT_0067", "DT_0043", "DT_0002",
|
41 |
-
"DT_0050", "DT_0017", "DT_0052", "DT_0025", "DT_0051", "DT_0037",
|
42 |
-
"DT_0024", "DT_0018", "DT_0068", "DT_0003", "DT_0066"
|
43 |
-
]
|
44 |
-
|
45 |
-
STATION_NAMES = {
|
46 |
-
"DT_0001": "인천", "DT_0002": "평택", "DT_0003": "영광", "DT_0008": "안산",
|
47 |
-
"DT_0017": "대산", "DT_0018": "군산", "DT_0024": "장항", "DT_0025": "보령",
|
48 |
-
"DT_0037": "어청도", "DT_0043": "영흥도", "DT_0050": "태안", "DT_0051": "서천마량",
|
49 |
-
"DT_0052": "인천송도", "DT_0065": "덕적도", "DT_0066": "향화도", "DT_0067": "안흥",
|
50 |
-
"DT_0068": "위도"
|
51 |
-
}
|
52 |
-
|
53 |
-
# --- 1. 유틸리티 함수들 ---
|
54 |
-
def clean_string(s):
|
55 |
-
"""문자열에서 특수 유니코드 문자 제거"""
|
56 |
-
if s is None:
|
57 |
-
return None
|
58 |
-
cleaned = s.replace('\u2028', '').replace('\u2029', '')
|
59 |
-
import re
|
60 |
-
cleaned = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned)
|
61 |
-
return cleaned.strip()
|
62 |
-
|
63 |
-
def get_supabase_client():
|
64 |
-
"""Supabase 클라이언트 생성"""
|
65 |
-
if not SUPABASE_AVAILABLE:
|
66 |
-
return None
|
67 |
-
|
68 |
-
try:
|
69 |
-
url = os.getenv("SUPABASE_URL")
|
70 |
-
key = os.getenv("SUPABASE_KEY")
|
71 |
-
|
72 |
-
if not url or not key:
|
73 |
-
print("Supabase 환경변수가 설정되지 않았습니다.")
|
74 |
-
return None
|
75 |
-
|
76 |
-
url = clean_string(url)
|
77 |
-
key = clean_string(key)
|
78 |
-
|
79 |
-
if not url.startswith('http'):
|
80 |
-
print(f"잘못된 SUPABASE_URL 형식: {url}")
|
81 |
-
return None
|
82 |
-
|
83 |
-
return create_client(url, key)
|
84 |
-
except Exception as e:
|
85 |
-
print(f"Supabase 연결 오류: {e}")
|
86 |
-
import traceback
|
87 |
-
traceback.print_exc()
|
88 |
-
return None
|
89 |
-
|
90 |
-
# --- 2. 예측 관련 함수들 ---
|
91 |
-
def get_harmonic_predictions(station_id, start_time, end_time):
|
92 |
-
"""해당 시간 범위의 조화 예측값 조회"""
|
93 |
-
supabase = get_supabase_client()
|
94 |
-
if not supabase:
|
95 |
-
print("Supabase 클라이언트를 생성할 수 없습니다.")
|
96 |
-
return []
|
97 |
-
|
98 |
-
try:
|
99 |
-
import pytz
|
100 |
-
kst = pytz.timezone('Asia/Seoul')
|
101 |
-
|
102 |
-
if start_time.tzinfo is None:
|
103 |
-
start_time = kst.localize(start_time)
|
104 |
-
if end_time.tzinfo is None:
|
105 |
-
end_time = kst.localize(end_time)
|
106 |
-
|
107 |
-
start_utc = start_time.astimezone(pytz.UTC)
|
108 |
-
end_utc = end_time.astimezone(pytz.UTC)
|
109 |
-
|
110 |
-
start_str = start_utc.strftime('%Y-%m-%dT%H:%M:%S+00:00')
|
111 |
-
end_str = end_utc.strftime('%Y-%m-%dT%H:%M:%S+00:00')
|
112 |
-
|
113 |
-
print(f"조화 예측 조회: {station_id}")
|
114 |
-
print(f" KST 시간: {start_time.strftime('%Y-%m-%d %H:%M')} ~ {end_time.strftime('%Y-%m-%d %H:%M')}")
|
115 |
-
print(f" UTC 시간: {start_str} ~ {end_str}")
|
116 |
-
|
117 |
-
result = supabase.table('harmonic_predictions')\
|
118 |
-
.select('predicted_at, harmonic_level')\
|
119 |
-
.eq('station_id', station_id)\
|
120 |
-
.gte('predicted_at', start_str)\
|
121 |
-
.lte('predicted_at', end_str)\
|
122 |
-
.order('predicted_at')\
|
123 |
-
.limit(1000)\
|
124 |
-
.execute()
|
125 |
-
|
126 |
-
if result.data:
|
127 |
-
print(f"조화 예측 데이터 {len(result.data)}개 조회 성공")
|
128 |
-
for i, item in enumerate(result.data[:3]):
|
129 |
-
print(f" 샘플 {i+1}: {item['predicted_at']}, {item['harmonic_level']:.2f}cm")
|
130 |
-
else:
|
131 |
-
print("조화 예측 데이터가 없습니다.")
|
132 |
-
check_result = supabase.table('harmonic_predictions')\
|
133 |
-
.select('predicted_at')\
|
134 |
-
.eq('station_id', station_id)\
|
135 |
-
.order('predicted_at')\
|
136 |
-
.limit(1)\
|
137 |
-
.execute()
|
138 |
-
|
139 |
-
if check_result.data:
|
140 |
-
print(f" 해당 관측소의 가장 빠른 예측 시간: {check_result.data[0]['predicted_at']}")
|
141 |
-
|
142 |
-
return result.data if result.data else []
|
143 |
-
except Exception as e:
|
144 |
-
print(f"조화 예측값 조회 오류: {e}")
|
145 |
-
traceback.print_exc()
|
146 |
-
return []
|
147 |
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
import pytz
|
154 |
-
kst = pytz.timezone('Asia/Seoul')
|
155 |
-
if last_time.tzinfo is None:
|
156 |
-
last_time = kst.localize(last_time)
|
157 |
-
|
158 |
-
start_time = last_time + timedelta(minutes=5)
|
159 |
-
end_time = last_time + timedelta(minutes=72*5)
|
160 |
-
|
161 |
-
harmonic_data = get_harmonic_predictions(station_id, start_time, end_time)
|
162 |
-
|
163 |
-
residual_flat = residual_predictions.flatten()
|
164 |
-
num_points = len(residual_flat)
|
165 |
-
|
166 |
-
if not harmonic_data:
|
167 |
-
print("조화 예측 데이터를 찾을 수 없습니다. 잔차 예측만 반환합니다.")
|
168 |
-
return {
|
169 |
-
'times': [last_time + timedelta(minutes=(i+1)*5) for i in range(num_points)],
|
170 |
-
'residual': residual_flat.tolist(),
|
171 |
-
'harmonic': [0.0] * num_points,
|
172 |
-
'final_tide': residual_flat.tolist()
|
173 |
-
}
|
174 |
-
|
175 |
-
final_results = {
|
176 |
-
'times': [],
|
177 |
-
'residual': [],
|
178 |
-
'harmonic': [],
|
179 |
-
'final_tide': []
|
180 |
-
}
|
181 |
-
|
182 |
-
harmonic_dict = {}
|
183 |
-
for h_data in harmonic_data:
|
184 |
-
h_time_str = h_data['predicted_at']
|
185 |
-
|
186 |
-
try:
|
187 |
-
if 'T' in h_time_str:
|
188 |
-
if h_time_str.endswith('Z'):
|
189 |
-
h_time = datetime.fromisoformat(h_time_str[:-1] + '+00:00')
|
190 |
-
elif '+' in h_time_str or '-' in h_time_str[-6:]:
|
191 |
-
h_time = datetime.fromisoformat(h_time_str)
|
192 |
-
else:
|
193 |
-
h_time = datetime.fromisoformat(h_time_str + '+00:00')
|
194 |
-
else:
|
195 |
-
from dateutil import parser
|
196 |
-
h_time = parser.parse(h_time_str)
|
197 |
-
|
198 |
-
if h_time.tzinfo is None:
|
199 |
-
h_time = pytz.UTC.localize(h_time)
|
200 |
-
h_time = h_time.astimezone(kst)
|
201 |
-
|
202 |
-
except Exception as e:
|
203 |
-
print(f"시간 파싱 오류: {h_time_str}, {e}")
|
204 |
-
continue
|
205 |
-
|
206 |
-
minutes = (h_time.minute // 5) * 5
|
207 |
-
h_time = h_time.replace(minute=minutes, second=0, microsecond=0)
|
208 |
-
harmonic_value = float(h_data['harmonic_level'])
|
209 |
-
harmonic_dict[h_time] = harmonic_value
|
210 |
-
|
211 |
-
for i, residual in enumerate(residual_flat):
|
212 |
-
pred_time = last_time + timedelta(minutes=(i+1)*5)
|
213 |
-
pred_time = pred_time.replace(second=0, microsecond=0)
|
214 |
-
|
215 |
-
harmonic_value = harmonic_dict.get(pred_time, 0.0)
|
216 |
-
|
217 |
-
if harmonic_value == 0.0 and harmonic_dict:
|
218 |
-
min_diff = float('inf')
|
219 |
-
for h_time, h_val in harmonic_dict.items():
|
220 |
-
diff = abs((h_time - pred_time).total_seconds())
|
221 |
-
if diff < min_diff and diff < 300:
|
222 |
-
min_diff = diff
|
223 |
-
harmonic_value = h_val
|
224 |
-
|
225 |
-
final_tide = float(residual) + harmonic_value
|
226 |
-
|
227 |
-
final_results['times'].append(pred_time)
|
228 |
-
final_results['residual'].append(float(residual))
|
229 |
-
final_results['harmonic'].append(harmonic_value)
|
230 |
-
final_results['final_tide'].append(final_tide)
|
231 |
-
|
232 |
-
print(f"최종 조위 계산 완료: {len(final_results['times'])}개 포인트")
|
233 |
-
return final_results
|
234 |
-
|
235 |
-
def save_predictions_to_supabase(station_id, prediction_results):
|
236 |
-
"""예측 결과를 Supabase에 저장"""
|
237 |
-
supabase = get_supabase_client()
|
238 |
-
if not supabase:
|
239 |
-
print("Supabase 클라이언트를 생성할 수 없습니다.")
|
240 |
-
return 0
|
241 |
-
|
242 |
-
try:
|
243 |
-
if prediction_results['times']:
|
244 |
-
start_time = prediction_results['times'][0].strftime('%Y-%m-%dT%H:%M:%S')
|
245 |
-
end_time = prediction_results['times'][-1].strftime('%Y-%m-%dT%H:%M:%S')
|
246 |
-
|
247 |
-
supabase.table('tide_predictions')\
|
248 |
-
.delete()\
|
249 |
-
.eq('station_id', station_id)\
|
250 |
-
.gte('predicted_at', start_time)\
|
251 |
-
.lte('predicted_at', end_time)\
|
252 |
-
.execute()
|
253 |
-
|
254 |
-
insert_data = []
|
255 |
-
for i in range(len(prediction_results['times'])):
|
256 |
-
time_str = prediction_results['times'][i].strftime('%Y-%m-%dT%H:%M:%S')
|
257 |
-
|
258 |
-
insert_data.append({
|
259 |
-
'station_id': station_id,
|
260 |
-
'predicted_at': time_str,
|
261 |
-
'predicted_residual': float(prediction_results['residual'][i]),
|
262 |
-
'harmonic_level': float(prediction_results['harmonic'][i]),
|
263 |
-
'final_tide_level': float(prediction_results['final_tide'][i])
|
264 |
-
})
|
265 |
-
|
266 |
-
result = supabase.table('tide_predictions')\
|
267 |
-
.insert(insert_data)\
|
268 |
-
.execute()
|
269 |
-
|
270 |
-
return len(insert_data)
|
271 |
-
except Exception as e:
|
272 |
-
print(f"예측 결과 저장 오류: {e}")
|
273 |
-
traceback.print_exc()
|
274 |
-
return 0
|
275 |
-
|
276 |
-
def get_common_args(station_id):
|
277 |
-
return [
|
278 |
-
"--model", "TimeXer", "--features", "MS", "--seq_len", "144", "--pred_len", "72",
|
279 |
-
"--label_len", "96", "--enc_in", "5", "--dec_in", "5", "--c_out", "1",
|
280 |
-
"--d_model", "256", "--d_ff", "512", "--n_heads", "8", "--e_layers", "1",
|
281 |
-
"--d_layers", "1", "--factor", "3", "--patch_len", "16", "--expand", "2", "--d_conv", "4"
|
282 |
-
]
|
283 |
-
|
284 |
-
def validate_csv_file(file_path, required_rows=144):
|
285 |
-
"""CSV 파일 유효성 검사"""
|
286 |
-
try:
|
287 |
-
df = pd.read_csv(file_path)
|
288 |
-
required_columns = ['date', 'air_pres', 'wind_dir', 'wind_speed', 'air_temp', 'residual']
|
289 |
-
missing_columns = [col for col in required_columns if col not in df.columns]
|
290 |
-
|
291 |
-
if missing_columns:
|
292 |
-
return False, f"필수 컬럼이 누락되었습니다: {missing_columns}"
|
293 |
-
|
294 |
-
if len(df) < required_rows:
|
295 |
-
return False, f"데이터가 부족합니다. 최소 {required_rows}행 필요, 현재 {len(df)}행"
|
296 |
-
|
297 |
-
return True, "파일이 유효합니다."
|
298 |
-
except Exception as e:
|
299 |
-
return False, f"파일 읽기 오류: {str(e)}"
|
300 |
-
|
301 |
-
def execute_inference_and_get_results(command):
|
302 |
-
"""inference 실행하고 결과 파일을 읽어서 반환"""
|
303 |
-
try:
|
304 |
-
print(f"실행 명령어: {' '.join(command)}")
|
305 |
-
result = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
306 |
-
|
307 |
-
if result.returncode != 0:
|
308 |
-
error_message = (
|
309 |
-
f"실행 실패 (Exit Code: {result.returncode}):\n\n"
|
310 |
-
f"--- 에러 로그 ---\n{result.stderr}\n\n"
|
311 |
-
f"--- 일반 출력 ---\n{result.stdout}"
|
312 |
-
)
|
313 |
-
raise gr.Error(error_message)
|
314 |
-
|
315 |
-
return True, result.stdout
|
316 |
-
except subprocess.TimeoutExpired:
|
317 |
-
raise gr.Error("실행 시간이 초과되었습니다. (5분 제한)")
|
318 |
-
except Exception as e:
|
319 |
-
raise gr.Error(f"내부 오류: {str(e)}")
|
320 |
-
|
321 |
-
def create_enhanced_prediction_plot(prediction_results, input_data, station_name):
|
322 |
-
"""잔차 + 조화 + 최종 조위를 모두 표시하는 향상된 플롯"""
|
323 |
-
try:
|
324 |
-
input_df = pd.read_csv(input_data.name)
|
325 |
-
input_df['date'] = pd.to_datetime(input_df['date'])
|
326 |
-
|
327 |
-
recent_data = input_df.tail(24)
|
328 |
-
future_times = pd.to_datetime(prediction_results['times'])
|
329 |
-
|
330 |
-
fig = go.Figure()
|
331 |
-
|
332 |
-
fig.add_trace(go.Scatter(
|
333 |
-
x=recent_data['date'],
|
334 |
-
y=recent_data['residual'],
|
335 |
-
mode='lines+markers',
|
336 |
-
name='실제 잔차조위',
|
337 |
-
line=dict(color='blue', width=2),
|
338 |
-
marker=dict(size=4)
|
339 |
-
))
|
340 |
-
|
341 |
-
fig.add_trace(go.Scatter(
|
342 |
-
x=future_times,
|
343 |
-
y=prediction_results['residual'],
|
344 |
-
mode='lines+markers',
|
345 |
-
name='잔차 예측',
|
346 |
-
line=dict(color='red', width=2, dash='dash'),
|
347 |
-
marker=dict(size=3)
|
348 |
-
))
|
349 |
-
|
350 |
-
if any(h != 0 for h in prediction_results['harmonic']):
|
351 |
-
fig.add_trace(go.Scatter(
|
352 |
-
x=future_times,
|
353 |
-
y=prediction_results['harmonic'],
|
354 |
-
mode='lines',
|
355 |
-
name='조화 예측',
|
356 |
-
line=dict(color='orange', width=2)
|
357 |
-
))
|
358 |
-
|
359 |
-
fig.add_trace(go.Scatter(
|
360 |
-
x=future_times,
|
361 |
-
y=prediction_results['final_tide'],
|
362 |
-
mode='lines+markers',
|
363 |
-
name='최종 조위',
|
364 |
-
line=dict(color='green', width=3),
|
365 |
-
marker=dict(size=4)
|
366 |
-
))
|
367 |
-
|
368 |
-
last_time = recent_data['date'].iloc[-1]
|
369 |
-
|
370 |
-
fig.add_annotation(
|
371 |
-
x=last_time,
|
372 |
-
y=0,
|
373 |
-
text="← 과거 | 미래 →",
|
374 |
-
showarrow=False,
|
375 |
-
yref="paper",
|
376 |
-
yshift=10,
|
377 |
-
font=dict(size=12, color="gray")
|
378 |
-
)
|
379 |
-
|
380 |
-
fig.update_layout(
|
381 |
-
title=f'{station_name} 통합 조위 예측 결과',
|
382 |
-
xaxis_title='시간',
|
383 |
-
yaxis_title='수위 (cm)',
|
384 |
-
hovermode='x unified',
|
385 |
-
height=600,
|
386 |
-
showlegend=True,
|
387 |
-
xaxis=dict(tickformat='%H:%M<br>%m/%d', gridcolor='lightgray', showgrid=True),
|
388 |
-
yaxis=dict(gridcolor='lightgray', showgrid=True),
|
389 |
-
plot_bgcolor='white'
|
390 |
-
)
|
391 |
-
|
392 |
-
return fig
|
393 |
-
except Exception as e:
|
394 |
-
print(f"Enhanced plot creation error: {e}")
|
395 |
-
traceback.print_exc()
|
396 |
-
fig = go.Figure()
|
397 |
-
fig.add_annotation(
|
398 |
-
text=f"시각화 생성 중 오류: {str(e)}",
|
399 |
-
xref="paper", yref="paper",
|
400 |
-
x=0.5, y=0.5, showarrow=False
|
401 |
-
)
|
402 |
-
return fig
|
403 |
-
|
404 |
-
def single_prediction(station_id, input_csv_file):
|
405 |
-
if input_csv_file is None:
|
406 |
-
raise gr.Error("예측을 위한 입력 파일을 업로드해��세요.")
|
407 |
-
|
408 |
-
is_valid, message = validate_csv_file(input_csv_file.name)
|
409 |
-
if not is_valid:
|
410 |
-
raise gr.Error(f"파일 오류: {message}")
|
411 |
-
|
412 |
-
station_name = STATION_NAMES.get(station_id, station_id)
|
413 |
-
|
414 |
-
common_args = get_common_args(station_id)
|
415 |
-
setting_name = f"long_term_forecast_{station_id}_144_72_TimeXer_TIDE_ftMS_sl144_ll96_pl72_dm256_nh8_el1_dl1_df512_expand2_dc4_fc3_ebtimeF_dtTrue_Exp_0"
|
416 |
-
checkpoint_path = f"./checkpoints/{setting_name}/checkpoint.pth"
|
417 |
-
scaler_path = f"./checkpoints/{setting_name}/scaler.gz"
|
418 |
-
|
419 |
-
if not os.path.exists(checkpoint_path):
|
420 |
-
raise gr.Error(f"모델 파일을 찾을 수 없습니다: {checkpoint_path}")
|
421 |
-
if not os.path.exists(scaler_path):
|
422 |
-
raise gr.Error(f"스케일러 파일을 찾을 수 없습니다: {scaler_path}")
|
423 |
-
|
424 |
-
command = ["python", "inference.py",
|
425 |
-
"--checkpoint_path", checkpoint_path,
|
426 |
-
"--scaler_path", scaler_path,
|
427 |
-
"--predict_input_file", input_csv_file.name] + common_args
|
428 |
-
|
429 |
-
gr.Info(f"{station_name}({station_id}) 통합 조위 예측을 실행중입니다...")
|
430 |
-
|
431 |
-
success, output = execute_inference_and_get_results(command)
|
432 |
-
|
433 |
-
try:
|
434 |
-
prediction_file = "pred_results/prediction_future.npy"
|
435 |
-
if os.path.exists(prediction_file):
|
436 |
-
residual_predictions = np.load(prediction_file)
|
437 |
-
|
438 |
-
input_df = pd.read_csv(input_csv_file.name)
|
439 |
-
input_df['date'] = pd.to_datetime(input_df['date'])
|
440 |
-
last_time = input_df['date'].iloc[-1]
|
441 |
-
|
442 |
-
prediction_results = calculate_final_tide(residual_predictions, station_id, last_time)
|
443 |
-
plot = create_enhanced_prediction_plot(prediction_results, input_csv_file, station_name)
|
444 |
-
|
445 |
-
has_harmonic = any(h != 0 for h in prediction_results['harmonic'])
|
446 |
-
|
447 |
-
if has_harmonic:
|
448 |
-
result_df = pd.DataFrame({
|
449 |
-
'예측 시간': [t.strftime('%Y-%m-%d %H:%M') for t in prediction_results['times']],
|
450 |
-
'잔차 예측 (cm)': [f"{val:.2f}" for val in prediction_results['residual']],
|
451 |
-
'조화 예측 (cm)': [f"{val:.2f}" for val in prediction_results['harmonic']],
|
452 |
-
'최종 조위 (cm)': [f"{val:.2f}" for val in prediction_results['final_tide']]
|
453 |
-
})
|
454 |
-
else:
|
455 |
-
result_df = pd.DataFrame({
|
456 |
-
'예측 시간': [t.strftime('%Y-%m-%d %H:%M') for t in prediction_results['times']],
|
457 |
-
'잔차 예측 (cm)': [f"{val:.2f}" for val in prediction_results['residual']]
|
458 |
-
})
|
459 |
-
|
460 |
-
saved_count = save_predictions_to_supabase(station_id, prediction_results)
|
461 |
-
if saved_count > 0:
|
462 |
-
save_message = f"\n💾 Supabase에 {saved_count}개 예측 결과 저장 완료!"
|
463 |
-
elif get_supabase_client() is None:
|
464 |
-
save_message = "\n⚠️ Supabase 연결 실패 (환경변수 확인 필요)"
|
465 |
-
else:
|
466 |
-
save_message = "\n⚠️ Supabase 저장 실패"
|
467 |
-
|
468 |
-
return plot, result_df, f"✅ 예측 완료!{save_message}\n\n{output}"
|
469 |
-
else:
|
470 |
-
return None, None, f"❌ 결과 파일을 찾을 수 없습니다.\n\n{output}"
|
471 |
-
except Exception as e:
|
472 |
-
print(f"Result processing error: {e}")
|
473 |
-
traceback.print_exc()
|
474 |
-
return None, None, f"❌ 결과 처리 중 오류: {str(e)}\n\n{output}"
|
475 |
-
|
476 |
-
# --- 3. Gemini 챗봇 관련 함수들 ---
|
477 |
-
def parse_intent_with_llm(message: str) -> dict:
|
478 |
-
"""LLM을 사용해 사용자 질문에서 의도를 분석하고 JSON으로 반환"""
|
479 |
-
if not GEMINI_API_KEY:
|
480 |
-
return {"error": "Gemini API 키가 설정되지 않았습니다."}
|
481 |
-
|
482 |
-
prompt = f"""
|
483 |
-
당신은 사용자의 자연어 질문을 분석하여 JSON 객체로 변환하는 전문가입니다.
|
484 |
-
질문에서 '관측소 이름', '원하는 정보', '시작 시간', '종료 시간'을 추출해주세요.
|
485 |
-
현재 시간은 {datetime.now(pytz.timezone('Asia/Seoul')).strftime('%Y-%m-%d %H:%M:%S')} KST 입니다.
|
486 |
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
- 관측소 이름이 없으면 '인천'을 기본값으로 사용하세요.
|
491 |
-
|
492 |
-
[사용자 질문]: {message}
|
493 |
-
[JSON 출력]:
|
494 |
-
"""
|
495 |
-
try:
|
496 |
-
genai.configure(api_key=GEMINI_API_KEY)
|
497 |
-
model = genai.GenerativeModel('gemini-1.5-flash', generation_config={"response_mime_type": "application/json"})
|
498 |
-
response = model.generate_content(prompt)
|
499 |
-
return json.loads(response.text)
|
500 |
-
except Exception as e:
|
501 |
-
return {"error": f"LLM 의도 분석 중 오류 발생: {e}"}
|
502 |
-
|
503 |
-
def retrieve_context_from_db(intent: dict) -> str:
|
504 |
-
"""분석된 의도를 바탕으로 데이터베이스에서 정보 검색"""
|
505 |
-
supabase = get_supabase_client()
|
506 |
-
if not supabase:
|
507 |
-
return "데이터베이스에 연결할 수 없습니다."
|
508 |
-
|
509 |
-
if "error" in intent:
|
510 |
-
return f"의도 분석에 실패했습니다: {intent['error']}"
|
511 |
-
|
512 |
-
station_name = intent.get("관측소 이름", "인천")
|
513 |
-
start_time_str = intent.get("시작 시간")
|
514 |
-
end_time_str = intent.get("종료 시간")
|
515 |
-
|
516 |
-
station_id = next((sid for sid, name in STATION_NAMES.items() if name == station_name), "DT_0001")
|
517 |
-
|
518 |
-
if not start_time_str or not end_time_str:
|
519 |
-
return "질문에서 시간 정보를 찾을 수 없습니다."
|
520 |
-
|
521 |
-
try:
|
522 |
-
start_time = date_parser.parse(start_time_str)
|
523 |
-
end_time = date_parser.parse(end_time_str)
|
524 |
-
|
525 |
-
start_query_str = start_time.strftime('%Y-%m-%d %H:%M:%S')
|
526 |
-
end_query_str = end_time.strftime('%Y-%m-%d %H:%M:%S')
|
527 |
-
|
528 |
-
print(f"'{station_id}'의 최종 예측 테이블 조회 중...")
|
529 |
-
print(f"조회 시간 범위 (KST): {start_query_str} ~ {end_query_str}")
|
530 |
-
|
531 |
-
result = supabase.table('tide_predictions')\
|
532 |
-
.select('*')\
|
533 |
-
.eq('station_id', station_id)\
|
534 |
-
.gte('predicted_at', start_query_str)\
|
535 |
-
.lte('predicted_at', end_query_str)\
|
536 |
-
.order('predicted_at')\
|
537 |
-
.execute()
|
538 |
-
|
539 |
-
if result.data:
|
540 |
-
kst = pytz.timezone('Asia/Seoul')
|
541 |
-
info_text = f"'{station_name}'의 '{start_time_str}'부터 '{end_time_str}'까지 조위 정보입니다.\n\n"
|
542 |
-
|
543 |
-
if len(result.data) > 10:
|
544 |
-
levels = [d['final_tide_level'] for d in result.data]
|
545 |
-
max_level = max(levels)
|
546 |
-
min_level = min(levels)
|
547 |
-
info_text += f"- 최고 조위: {max_level:.1f}cm\n- 최저 조위: {min_level:.1f}cm"
|
548 |
-
else:
|
549 |
-
for d in result.data:
|
550 |
-
time_kst = date_parser.parse(d['predicted_at']).strftime('%H:%M')
|
551 |
-
info_text += f"- {time_kst}: 최종 조위 {d['final_tide_level']:.1f}cm (잔차 {d['predicted_residual']:.1f}cm)\n"
|
552 |
-
return info_text
|
553 |
-
else:
|
554 |
-
return "해당 기간의 예측 데이터를 찾을 수 없습니다. '통합 조위 예측' 탭에서 먼저 예측을 실행해주세요."
|
555 |
-
|
556 |
-
except Exception as e:
|
557 |
-
return f"데이터 검색 중 오류 발생: {traceback.format_exc()}"
|
558 |
-
|
559 |
-
def process_chatbot_query_with_llm(message: str, history: list) -> str:
|
560 |
-
"""최종 RAG 파이프라인"""
|
561 |
-
intent = parse_intent_with_llm(message)
|
562 |
-
retrieved_data = retrieve_context_from_db(intent)
|
563 |
-
|
564 |
-
prompt = f"""당신은 친절한 해양 조위 정보 전문가입니다. 주어진 [검색된 데이터]를 바탕으로 사용자의 [질문]에 대해 자연스러운 문장으로 답변해주세요.
|
565 |
-
[검색된 데이터]: {retrieved_data}
|
566 |
-
[사용자 질문]: {message}
|
567 |
-
[답변]:"""
|
568 |
-
|
569 |
-
try:
|
570 |
-
genai.configure(api_key=GEMINI_API_KEY)
|
571 |
-
model = genai.GenerativeModel('gemini-1.5-flash')
|
572 |
-
response = model.generate_content(prompt)
|
573 |
-
return response.text
|
574 |
-
except Exception as e:
|
575 |
-
return f"Gemini 답변 생성 중 오류가 발생했습니다: {e}"
|
576 |
-
|
577 |
-
# --- 4. API 함수들 ---
|
578 |
-
def api_get_current_tide(station_id):
|
579 |
-
"""현재 조위 조회"""
|
580 |
-
supabase = get_supabase_client()
|
581 |
-
if not supabase:
|
582 |
-
return {"error": "Database connection failed"}
|
583 |
-
|
584 |
-
result = supabase.table('tide_predictions')\
|
585 |
-
.select('*')\
|
586 |
-
.eq('station_id', station_id)\
|
587 |
-
.gte('predicted_at', datetime.now().isoformat())\
|
588 |
-
.order('predicted_at')\
|
589 |
-
.limit(1)\
|
590 |
-
.execute()
|
591 |
-
|
592 |
-
if result.data:
|
593 |
-
data = result.data[0]
|
594 |
-
return {
|
595 |
-
"station_id": station_id,
|
596 |
-
"time": data['predicted_at'],
|
597 |
-
"tide_level": data.get('final_tide_level', 0),
|
598 |
-
"residual": data.get('predicted_residual', 0),
|
599 |
-
"harmonic": data.get('harmonic_level', 0)
|
600 |
-
}
|
601 |
-
return {"error": "No data found"}
|
602 |
-
|
603 |
-
def api_get_extremes(station_id, hours=24):
|
604 |
-
"""만조/간조 시간 조회"""
|
605 |
-
supabase = get_supabase_client()
|
606 |
-
if not supabase:
|
607 |
-
return {"error": "Database connection failed"}
|
608 |
-
|
609 |
-
end_time = datetime.now() + timedelta(hours=hours)
|
610 |
-
|
611 |
-
result = supabase.table('tide_predictions')\
|
612 |
-
.select('predicted_at, final_tide_level')\
|
613 |
-
.eq('station_id', station_id)\
|
614 |
-
.gte('predicted_at', datetime.now().isoformat())\
|
615 |
-
.lte('predicted_at', end_time.isoformat())\
|
616 |
-
.order('predicted_at')\
|
617 |
-
.execute()
|
618 |
-
|
619 |
-
if not result.data:
|
620 |
-
return {"error": "No data found"}
|
621 |
-
|
622 |
-
extremes = []
|
623 |
-
data = result.data
|
624 |
-
|
625 |
-
for i in range(1, len(data) - 1):
|
626 |
-
prev_level = data[i-1]['final_tide_level']
|
627 |
-
curr_level = data[i]['final_tide_level']
|
628 |
-
next_level = data[i+1]['final_tide_level']
|
629 |
-
|
630 |
-
if curr_level > prev_level and curr_level > next_level:
|
631 |
-
extremes.append({
|
632 |
-
'type': 'high',
|
633 |
-
'time': data[i]['predicted_at'],
|
634 |
-
'level': curr_level
|
635 |
-
})
|
636 |
-
elif curr_level < prev_level and curr_level < next_level:
|
637 |
-
extremes.append({
|
638 |
-
'type': 'low',
|
639 |
-
'time': data[i]['predicted_at'],
|
640 |
-
'level': curr_level
|
641 |
-
})
|
642 |
-
|
643 |
-
return {
|
644 |
-
"station_id": station_id,
|
645 |
-
"hours": hours,
|
646 |
-
"count": len(extremes),
|
647 |
-
"extremes": extremes
|
648 |
-
}
|
649 |
-
def api_get_historical_tide(station_id, date_str, hours=24):
|
650 |
-
"""과거 특정 날짜의 조위 데이터 조회"""
|
651 |
-
supabase = get_supabase_client()
|
652 |
-
if not supabase:
|
653 |
-
return {"error": "Database connection failed"}
|
654 |
-
|
655 |
-
try:
|
656 |
-
# 날짜 파싱 (YYYY-MM-DD 형식)
|
657 |
-
start_date = datetime.strptime(date_str, "%Y-%m-%d")
|
658 |
-
end_date = start_date + timedelta(hours=hours)
|
659 |
-
|
660 |
-
# 과거 예측 데이터 조회
|
661 |
-
result = supabase.table('tide_predictions')\
|
662 |
-
.select('predicted_at, final_tide_level, harmonic_level, predicted_residual')\
|
663 |
-
.eq('station_id', station_id)\
|
664 |
-
.gte('predicted_at', start_date.isoformat())\
|
665 |
-
.lte('predicted_at', end_date.isoformat())\
|
666 |
-
.order('predicted_at')\
|
667 |
-
.execute()
|
668 |
-
|
669 |
-
if result.data:
|
670 |
-
# 통계 계산
|
671 |
-
levels = [d['final_tide_level'] for d in result.data]
|
672 |
-
|
673 |
-
return {
|
674 |
-
"station_id": station_id,
|
675 |
-
"date": date_str,
|
676 |
-
"hours": hours,
|
677 |
-
"count": len(result.data),
|
678 |
-
"statistics": {
|
679 |
-
"max": max(levels),
|
680 |
-
"min": min(levels),
|
681 |
-
"avg": sum(levels) / len(levels)
|
682 |
-
},
|
683 |
-
"data": result.data[:100] # 최대 100개만 반환
|
684 |
-
}
|
685 |
-
|
686 |
-
# 예측 데이터가 없으면 관측 데이터 확인
|
687 |
-
result = supabase.table('tide_observations')\
|
688 |
-
.select('observed_at, residual, air_pres, wind_speed, air_temp')\
|
689 |
-
.eq('station_id', station_id)\
|
690 |
-
.gte('observed_at', start_date.isoformat())\
|
691 |
-
.lte('observed_at', end_date.isoformat())\
|
692 |
-
.order('observed_at')\
|
693 |
-
.execute()
|
694 |
-
|
695 |
-
if result.data:
|
696 |
-
return {
|
697 |
-
"station_id": station_id,
|
698 |
-
"date": date_str,
|
699 |
-
"type": "observation", # 관측 데이터임을 표시
|
700 |
-
"count": len(result.data),
|
701 |
-
"data": result.data[:100]
|
702 |
-
}
|
703 |
-
|
704 |
-
return {"error": "No historical data found for this date"}
|
705 |
-
|
706 |
-
except Exception as e:
|
707 |
-
return {"error": f"Date parsing error: {str(e)}"}
|
708 |
-
|
709 |
-
def api_get_historical_extremes(station_id, date_str):
|
710 |
-
"""과거 특정 날짜의 만조/간조 정보"""
|
711 |
-
supabase = get_supabase_client()
|
712 |
-
if not supabase:
|
713 |
-
return {"error": "Database connection failed"}
|
714 |
-
|
715 |
-
try:
|
716 |
-
# 하루 전체 데이터
|
717 |
-
start_date = datetime.strptime(date_str, "%Y-%m-%d")
|
718 |
-
end_date = start_date + timedelta(days=1)
|
719 |
-
|
720 |
-
result = supabase.table('tide_predictions')\
|
721 |
-
.select('predicted_at, final_tide_level')\
|
722 |
-
.eq('station_id', station_id)\
|
723 |
-
.gte('predicted_at', start_date.isoformat())\
|
724 |
-
.lt('predicted_at', end_date.isoformat())\
|
725 |
-
.order('predicted_at')\
|
726 |
-
.execute()
|
727 |
-
|
728 |
-
if not result.data or len(result.data) < 3:
|
729 |
-
return {"error": "Insufficient data for this date"}
|
730 |
-
|
731 |
-
# 극값 찾기
|
732 |
-
extremes = []
|
733 |
-
data = result.data
|
734 |
-
|
735 |
-
for i in range(1, len(data) - 1):
|
736 |
-
prev_level = data[i-1]['final_tide_level']
|
737 |
-
curr_level = data[i]['final_tide_level']
|
738 |
-
next_level = data[i+1]['final_tide_level']
|
739 |
-
|
740 |
-
if curr_level > prev_level and curr_level > next_level:
|
741 |
-
extremes.append({
|
742 |
-
'type': 'high',
|
743 |
-
'time': data[i]['predicted_at'],
|
744 |
-
'level': curr_level
|
745 |
-
})
|
746 |
-
elif curr_level < prev_level and curr_level < next_level:
|
747 |
-
extremes.append({
|
748 |
-
'type': 'low',
|
749 |
-
'time': data[i]['predicted_at'],
|
750 |
-
'level': curr_level
|
751 |
-
})
|
752 |
-
|
753 |
-
# 최고/최저 찾기
|
754 |
-
all_levels = [d['final_tide_level'] for d in data]
|
755 |
-
daily_max = max(all_levels)
|
756 |
-
daily_min = min(all_levels)
|
757 |
-
|
758 |
-
return {
|
759 |
-
"station_id": station_id,
|
760 |
-
"date": date_str,
|
761 |
-
"daily_max": daily_max,
|
762 |
-
"daily_min": daily_min,
|
763 |
-
"daily_range": daily_max - daily_min,
|
764 |
-
"extremes": extremes,
|
765 |
-
"high_tide_count": len([e for e in extremes if e['type'] == 'high']),
|
766 |
-
"low_tide_count": len([e for e in extremes if e['type'] == 'low'])
|
767 |
-
}
|
768 |
-
|
769 |
-
except Exception as e:
|
770 |
-
return {"error": f"Error: {str(e)}"}
|
771 |
-
|
772 |
-
def api_compare_dates(station_id, date1, date2):
|
773 |
-
"""두 날짜의 조위 패턴 비교"""
|
774 |
-
supabase = get_supabase_client()
|
775 |
-
if not supabase:
|
776 |
-
return {"error": "Database connection failed"}
|
777 |
-
|
778 |
-
try:
|
779 |
-
results = {}
|
780 |
-
|
781 |
-
for date_str in [date1, date2]:
|
782 |
-
start_date = datetime.strptime(date_str, "%Y-%m-%d")
|
783 |
-
end_date = start_date + timedelta(days=1)
|
784 |
-
|
785 |
-
result = supabase.table('tide_predictions')\
|
786 |
-
.select('predicted_at, final_tide_level')\
|
787 |
-
.eq('station_id', station_id)\
|
788 |
-
.gte('predicted_at', start_date.isoformat())\
|
789 |
-
.lt('predicted_at', end_date.isoformat())\
|
790 |
-
.order('predicted_at')\
|
791 |
-
.execute()
|
792 |
-
|
793 |
-
if result.data:
|
794 |
-
levels = [d['final_tide_level'] for d in result.data]
|
795 |
-
results[date_str] = {
|
796 |
-
"max": max(levels),
|
797 |
-
"min": min(levels),
|
798 |
-
"avg": sum(levels) / len(levels),
|
799 |
-
"range": max(levels) - min(levels)
|
800 |
-
}
|
801 |
-
|
802 |
-
if len(results) == 2:
|
803 |
-
# 차이 계산
|
804 |
-
diff = {
|
805 |
-
"max_diff": results[date1]["max"] - results[date2]["max"],
|
806 |
-
"min_diff": results[date1]["min"] - results[date2]["min"],
|
807 |
-
"avg_diff": results[date1]["avg"] - results[date2]["avg"],
|
808 |
-
"range_diff": results[date1]["range"] - results[date2]["range"]
|
809 |
-
}
|
810 |
-
|
811 |
-
return {
|
812 |
-
"station_id": station_id,
|
813 |
-
"date1": {**{"date": date1}, **results[date1]},
|
814 |
-
"date2": {**{"date": date2}, **results[date2]},
|
815 |
-
"difference": diff
|
816 |
-
}
|
817 |
-
|
818 |
-
return {"error": "Data not available for both dates"}
|
819 |
-
|
820 |
-
except Exception as e:
|
821 |
-
return {"error": f"Error: {str(e)}"}
|
822 |
-
|
823 |
-
def api_get_monthly_summary(station_id, year, month):
|
824 |
-
"""월간 조위 요약 통계"""
|
825 |
-
supabase = get_supabase_client()
|
826 |
-
if not supabase:
|
827 |
-
return {"error": "Database connection failed"}
|
828 |
-
|
829 |
-
try:
|
830 |
-
# 월 시작/종료 날짜
|
831 |
-
start_date = datetime(year, month, 1)
|
832 |
-
if month == 12:
|
833 |
-
end_date = datetime(year + 1, 1, 1)
|
834 |
-
else:
|
835 |
-
end_date = datetime(year, month + 1, 1)
|
836 |
-
|
837 |
-
result = supabase.table('tide_predictions')\
|
838 |
-
.select('predicted_at, final_tide_level')\
|
839 |
-
.eq('station_id', station_id)\
|
840 |
-
.gte('predicted_at', start_date.isoformat())\
|
841 |
-
.lt('predicted_at', end_date.isoformat())\
|
842 |
-
.execute()
|
843 |
-
|
844 |
-
if not result.data:
|
845 |
-
return {"error": "No data for this month"}
|
846 |
-
|
847 |
-
# 일별 통계 계산
|
848 |
-
daily_stats = {}
|
849 |
-
for item in result.data:
|
850 |
-
date = item['predicted_at'][:10] # YYYY-MM-DD
|
851 |
-
if date not in daily_stats:
|
852 |
-
daily_stats[date] = []
|
853 |
-
daily_stats[date].append(item['final_tide_level'])
|
854 |
-
|
855 |
-
# 월간 통계
|
856 |
-
all_levels = [d['final_tide_level'] for d in result.data]
|
857 |
-
monthly_max = max(all_levels)
|
858 |
-
monthly_min = min(all_levels)
|
859 |
-
|
860 |
-
# 가장 높았던 날과 낮았던 날 찾기
|
861 |
-
highest_day = None
|
862 |
-
lowest_day = None
|
863 |
-
highest_value = 0
|
864 |
-
lowest_value = 9999
|
865 |
-
|
866 |
-
for date, levels in daily_stats.items():
|
867 |
-
day_max = max(levels)
|
868 |
-
day_min = min(levels)
|
869 |
-
|
870 |
-
if day_max > highest_value:
|
871 |
-
highest_value = day_max
|
872 |
-
highest_day = date
|
873 |
-
|
874 |
-
if day_min < lowest_value:
|
875 |
-
lowest_value = day_min
|
876 |
-
lowest_day = date
|
877 |
-
|
878 |
-
return {
|
879 |
-
"station_id": station_id,
|
880 |
-
"year": year,
|
881 |
-
"month": month,
|
882 |
-
"statistics": {
|
883 |
-
"monthly_max": monthly_max,
|
884 |
-
"monthly_min": monthly_min,
|
885 |
-
"monthly_avg": sum(all_levels) / len(all_levels),
|
886 |
-
"monthly_range": monthly_max - monthly_min,
|
887 |
-
"total_observations": len(result.data),
|
888 |
-
"days_with_data": len(daily_stats)
|
889 |
-
},
|
890 |
-
"extreme_days": {
|
891 |
-
"highest_tide_day": highest_day,
|
892 |
-
"highest_tide_value": highest_value,
|
893 |
-
"lowest_tide_day": lowest_day,
|
894 |
-
"lowest_tide_value": lowest_value
|
895 |
-
}
|
896 |
-
}
|
897 |
-
|
898 |
-
except Exception as e:
|
899 |
-
return {"error": f"Error: {str(e)}"}
|
900 |
|
901 |
-
#
|
902 |
-
|
903 |
-
|
904 |
-
|
905 |
-
|
906 |
-
client = get_supabase_client()
|
907 |
-
supabase_status = "🟢 연결됨" if client else "🔴 연결 안됨 (환경변수 확인 필요)"
|
908 |
-
gemini_status = "🟢 연결됨" if GEMINI_AVAILABLE and GEMINI_API_KEY else "🔴 연결 안됨 (환경변수 확인 필요)"
|
909 |
-
gr.Markdown(f"**Supabase 상태**: {supabase_status} | **Gemini 상태**: {gemini_status}")
|
910 |
|
911 |
-
|
912 |
-
|
913 |
-
with gr.TabItem("🔮 통합 조위 예측"):
|
914 |
-
gr.Markdown("""
|
915 |
-
### 🌟 새로운 기능
|
916 |
-
- **잔차 예측**: TimeXer 모델로 기상 영향 예측
|
917 |
-
- **조화 예측**: MATLAB 조화분석으로 천체 영향 예측 (Supabase 연결 시)
|
918 |
-
- **최종 조위**: 잔차 + 조화 = 완전한 조위 예측
|
919 |
-
- **자동 저장**: 예측 결과를 데이터베이스에 자동 저장
|
920 |
-
""")
|
921 |
-
|
922 |
-
with gr.Row():
|
923 |
-
with gr.Column(scale=1):
|
924 |
-
station_dropdown1 = gr.Dropdown(
|
925 |
-
choices=[(f"{STATION_NAMES[s]} ({s})", s) for s in STATIONS],
|
926 |
-
label="관측소 선택",
|
927 |
-
value=STATIONS[0]
|
928 |
-
)
|
929 |
-
with gr.Column(scale=2):
|
930 |
-
file_input1 = gr.File(
|
931 |
-
label="입력 데이터 (.csv 파일)",
|
932 |
-
file_types=[".csv"],
|
933 |
-
file_count="single"
|
934 |
-
)
|
935 |
-
|
936 |
-
submit_btn1 = gr.Button("🚀 예측 실행", variant="primary", size="lg")
|
937 |
-
|
938 |
-
with gr.Row():
|
939 |
-
with gr.Column(scale=2):
|
940 |
-
plot_output1 = gr.Plot(label="예측 결과 시각화")
|
941 |
-
with gr.Column(scale=1):
|
942 |
-
table_output1 = gr.Dataframe(
|
943 |
-
label="예측 결과 테이블"
|
944 |
-
)
|
945 |
-
|
946 |
-
text_output1 = gr.Textbox(
|
947 |
-
label="실행 로그",
|
948 |
-
lines=5,
|
949 |
-
show_copy_button=True
|
950 |
-
)
|
951 |
-
|
952 |
-
submit_btn1.click(
|
953 |
-
fn=single_prediction,
|
954 |
-
inputs=[station_dropdown1, file_input1],
|
955 |
-
outputs=[plot_output1, table_output1, text_output1]
|
956 |
-
)
|
957 |
-
|
958 |
-
# 2번 탭: AI 조위 챗봇
|
959 |
-
with gr.TabItem("💬 AI 조위 챗봇"):
|
960 |
-
gr.Markdown("""
|
961 |
-
### 💡 AI 조위 정보 도우미 (Gemini 기반)
|
962 |
-
데이터베이스에 저장된 예측 정보를 바탕으로 AI가 원하는 정보를 찾아옵니다.
|
963 |
-
|
964 |
-
**질문 예시:**
|
965 |
-
- "현재 인천 조위 알려줘"
|
966 |
-
- "내일 오후 3시 평택 조위는?"
|
967 |
-
- "오늘 만조 시간 알려줘"
|
968 |
-
""")
|
969 |
-
|
970 |
-
gr.ChatInterface(
|
971 |
-
fn=process_chatbot_query_with_llm,
|
972 |
-
title="",
|
973 |
-
examples=[
|
974 |
-
"현재 인천 조위 알려줘",
|
975 |
-
"내일 오후 3시 평택 조위는?",
|
976 |
-
"오늘 만조 시간 알려줘"
|
977 |
-
]
|
978 |
-
)
|
979 |
-
|
980 |
-
# 3번 탭: API
|
981 |
-
with gr.TabItem("🔌 API"):
|
982 |
-
gr.Markdown("""
|
983 |
-
## API 엔드포인트
|
984 |
-
|
985 |
-
이 앱은 자동으로 API를 제공합니다:
|
986 |
-
|
987 |
-
### 사용 가능한 엔드포인트:
|
988 |
-
- `/api/current_tide` - 현재 조위
|
989 |
-
- `/api/extremes` - 만조/간조 시간
|
990 |
-
|
991 |
-
### 사용 예시:
|
992 |
-
```python
|
993 |
-
from gradio_client import Client
|
994 |
-
|
995 |
-
client = Client("https://your-space.hf.space/")
|
996 |
-
result = client.predict(
|
997 |
-
"DT_0001",
|
998 |
-
api_name="/current_tide"
|
999 |
-
)
|
1000 |
-
print(result)
|
1001 |
-
```
|
1002 |
-
""")
|
1003 |
-
|
1004 |
-
# API 테스트 인터페이스
|
1005 |
-
with gr.Row():
|
1006 |
-
with gr.Column():
|
1007 |
-
gr.Markdown("### 현재 조위 API 테스트")
|
1008 |
-
api_station_input = gr.Textbox(
|
1009 |
-
label="관측소 ID",
|
1010 |
-
value="DT_0001"
|
1011 |
-
)
|
1012 |
-
api_current_btn = gr.Button("조회")
|
1013 |
-
api_current_output = gr.JSON(label="결과")
|
1014 |
-
|
1015 |
-
api_current_btn.click(
|
1016 |
-
fn=api_get_current_tide,
|
1017 |
-
inputs=api_station_input,
|
1018 |
-
outputs=api_current_output,
|
1019 |
-
api_name="current_tide"
|
1020 |
-
)
|
1021 |
-
|
1022 |
-
with gr.Column():
|
1023 |
-
gr.Markdown("### 만조/간조 API 테스트")
|
1024 |
-
api_extreme_station = gr.Textbox(
|
1025 |
-
label="관측소 ID",
|
1026 |
-
value="DT_0001"
|
1027 |
-
)
|
1028 |
-
api_extreme_hours = gr.Number(
|
1029 |
-
label="시간",
|
1030 |
-
value=24
|
1031 |
-
)
|
1032 |
-
api_extreme_btn = gr.Button("조회")
|
1033 |
-
api_extreme_output = gr.JSON(label="결과")
|
1034 |
-
|
1035 |
-
api_extreme_btn.click(
|
1036 |
-
fn=api_get_extremes,
|
1037 |
-
inputs=[api_extreme_station, api_extreme_hours],
|
1038 |
-
outputs=api_extreme_output,
|
1039 |
-
api_name="extremes"
|
1040 |
-
)
|
1041 |
-
with gr.TabItem("📜 과거 데이터"):
|
1042 |
-
gr.Markdown("""
|
1043 |
-
### 과거 조위 데이터 조회
|
1044 |
-
특정 날짜의 조위 정보를 확인할 수 있습니다.
|
1045 |
-
""")
|
1046 |
-
|
1047 |
-
with gr.Row():
|
1048 |
-
with gr.Column():
|
1049 |
-
hist_station = gr.Dropdown(
|
1050 |
-
choices=[(f"{STATION_NAMES[s]} ({s})", s) for s in STATIONS],
|
1051 |
-
label="관측소 선택",
|
1052 |
-
value=STATIONS[0]
|
1053 |
-
)
|
1054 |
-
hist_date = gr.Textbox(
|
1055 |
-
label="날짜 (YYYY-MM-DD)",
|
1056 |
-
value=datetime.now().strftime("%Y-%m-%d")
|
1057 |
-
)
|
1058 |
-
hist_hours = gr.Number(
|
1059 |
-
label="조회 시간 (시간)",
|
1060 |
-
value=24,
|
1061 |
-
minimum=1,
|
1062 |
-
maximum=168
|
1063 |
-
)
|
1064 |
-
|
1065 |
-
hist_btn = gr.Button("조회", variant="primary")
|
1066 |
-
|
1067 |
-
with gr.Column():
|
1068 |
-
hist_output = gr.JSON(label="조회 결과")
|
1069 |
-
|
1070 |
-
hist_btn.click(
|
1071 |
-
fn=api_get_historical_tide,
|
1072 |
-
inputs=[hist_station, hist_date, hist_hours],
|
1073 |
-
outputs=hist_output,
|
1074 |
-
api_name="historical_tide"
|
1075 |
-
)
|
1076 |
-
|
1077 |
-
# 과거 만조/간조
|
1078 |
-
with gr.Row():
|
1079 |
-
with gr.Column():
|
1080 |
-
gr.Markdown("### 과거 만조/간조 정보")
|
1081 |
-
ext_station = gr.Dropdown(
|
1082 |
-
choices=[(f"{STATION_NAMES[s]} ({s})", s) for s in STATIONS],
|
1083 |
-
label="관측소",
|
1084 |
-
value=STATIONS[0]
|
1085 |
-
)
|
1086 |
-
ext_date = gr.Textbox(
|
1087 |
-
label="날짜 (YYYY-MM-DD)",
|
1088 |
-
value=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
1089 |
-
)
|
1090 |
-
ext_btn = gr.Button("만조/간조 조회")
|
1091 |
-
ext_output = gr.JSON(label="만조/간조 정보")
|
1092 |
-
|
1093 |
-
with gr.Column():
|
1094 |
-
gr.Markdown("### 날짜 비교")
|
1095 |
-
comp_station = gr.Dropdown(
|
1096 |
-
choices=[(f"{STATION_NAMES[s]} ({s})", s) for s in STATIONS],
|
1097 |
-
label="관측소",
|
1098 |
-
value=STATIONS[0]
|
1099 |
-
)
|
1100 |
-
comp_date1 = gr.Textbox(
|
1101 |
-
label="날짜 1",
|
1102 |
-
value=datetime.now().strftime("%Y-%m-%d")
|
1103 |
-
)
|
1104 |
-
comp_date2 = gr.Textbox(
|
1105 |
-
label="날짜 2",
|
1106 |
-
value=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
1107 |
-
)
|
1108 |
-
comp_btn = gr.Button("비교")
|
1109 |
-
comp_output = gr.JSON(label="비교 결과")
|
1110 |
-
|
1111 |
-
ext_btn.click(
|
1112 |
-
fn=api_get_historical_extremes,
|
1113 |
-
inputs=[ext_station, ext_date],
|
1114 |
-
outputs=ext_output,
|
1115 |
-
api_name="historical_extremes"
|
1116 |
-
)
|
1117 |
-
|
1118 |
-
comp_btn.click(
|
1119 |
-
fn=api_compare_dates,
|
1120 |
-
inputs=[comp_station, comp_date1, comp_date2],
|
1121 |
-
outputs=comp_output,
|
1122 |
-
api_name="compare_dates"
|
1123 |
-
)
|
1124 |
-
|
1125 |
-
# 월간 요약
|
1126 |
-
gr.Markdown("### 월간 요약 통계")
|
1127 |
-
with gr.Row():
|
1128 |
-
month_station = gr.Dropdown(
|
1129 |
-
choices=[(f"{STATION_NAMES[s]} ({s})", s) for s in STATIONS],
|
1130 |
-
label="관측소",
|
1131 |
-
value=STATIONS[0]
|
1132 |
-
)
|
1133 |
-
month_year = gr.Number(
|
1134 |
-
label="년도",
|
1135 |
-
value=datetime.now().year,
|
1136 |
-
precision=0
|
1137 |
-
)
|
1138 |
-
month_month = gr.Number(
|
1139 |
-
label="월",
|
1140 |
-
value=datetime.now().month,
|
1141 |
-
minimum=1,
|
1142 |
-
maximum=12,
|
1143 |
-
precision=0
|
1144 |
-
)
|
1145 |
-
month_btn = gr.Button("월간 통계 조회")
|
1146 |
-
|
1147 |
-
month_output = gr.JSON(label="월간 통계")
|
1148 |
-
|
1149 |
-
month_btn.click(
|
1150 |
-
fn=api_get_monthly_summary,
|
1151 |
-
inputs=[month_station, month_year, month_month],
|
1152 |
-
outputs=month_output,
|
1153 |
-
api_name="monthly_summary"
|
1154 |
-
)
|
1155 |
-
|
1156 |
-
if __name__ == "__main__":
|
1157 |
-
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import warnings
|
2 |
+
from dotenv import load_dotenv
|
3 |
|
4 |
+
# Load environment variables from .env file
|
5 |
+
load_dotenv()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
+
# Import handlers and UI creator from modules
|
8 |
+
from prediction import single_prediction
|
9 |
+
from chatbot import process_chatbot_query_with_llm
|
10 |
+
from ui import create_ui
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
|
12 |
+
if __name__ == "__main__":
|
13 |
+
# Suppress warnings for a cleaner output
|
14 |
+
warnings.filterwarnings('ignore')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
|
16 |
+
# Create the Gradio UI by passing the handlers to the UI generator
|
17 |
+
demo = create_ui(
|
18 |
+
prediction_handler=single_prediction,
|
19 |
+
chatbot_handler=process_chatbot_query_with_llm
|
20 |
+
)
|
|
|
|
|
|
|
|
|
21 |
|
22 |
+
# Launch the application
|
23 |
+
demo.launch(share=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatbot.py
ADDED
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
from datetime import datetime
|
4 |
+
import pytz
|
5 |
+
import traceback
|
6 |
+
from dateutil import parser as date_parser
|
7 |
+
|
8 |
+
# Local imports
|
9 |
+
from supabase_utils import get_supabase_client
|
10 |
+
|
11 |
+
# Attempt to import Gemini
|
12 |
+
try:
|
13 |
+
import google.generativeai as genai
|
14 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
15 |
+
if GEMINI_API_KEY:
|
16 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
17 |
+
GEMINI_AVAILABLE = True
|
18 |
+
else:
|
19 |
+
GEMINI_AVAILABLE = False
|
20 |
+
except ImportError:
|
21 |
+
GEMINI_AVAILABLE = False
|
22 |
+
|
23 |
+
# Station names (dependency for retrieve_context_from_db)
|
24 |
+
STATION_NAMES = {
|
25 |
+
"DT_0001": "인천", "DT_0065": "평택", "DT_0008": "안산", "DT_0067": "대산",
|
26 |
+
"DT_0043": "보령", "DT_0002": "군산", "DT_0050": "목포", "DT_0017": "제주",
|
27 |
+
"DT_0052": "여수", "DT_0025": "마산", "DT_0051": "부산", "DT_0037": "포항",
|
28 |
+
"DT_0068": "위도"
|
29 |
+
}
|
30 |
+
|
31 |
+
def parse_intent_with_llm(message: str) -> dict:
|
32 |
+
"""LLM을 사용해 사용자 질문에서 의도를 분석하고 JSON으로 반환"""
|
33 |
+
if not GEMINI_AVAILABLE:
|
34 |
+
return {"error": "Gemini API를 사용할 수 없습니다. API 키를 확인하세요."}
|
35 |
+
|
36 |
+
prompt = f"""
|
37 |
+
당신은 사용자의 자연어 질문을 분석하여 JSON 객체로 변환하는 전문가입니다.
|
38 |
+
질문에서 '관측소 이름', '원하는 정보', '시작 시간', '종료 시간'을 추출해주세요.
|
39 |
+
현재 시간은 {datetime.now(pytz.timezone('Asia/Seoul')).strftime('%Y-%m-%d %H:%M:%S')} KST 입니다.
|
40 |
+
|
41 |
+
- '원하는 정보'는 '특정 시간 조위' 또는 '구간 조위' 중 하나여야 합니다.
|
42 |
+
- '시작 시간'과 '종료 시간'은 'YYYY-MM-DD HH:MM:SS' 형식으로 변환해주세요.
|
43 |
+
- 단일 시간이면 시작과 종료 시간을 동일하게 설정하고, 구간이면 그에 맞게 설정하세요.
|
44 |
+
- 관측소 이름이 없으면 '인천'을 기본값으로 사용하세요.
|
45 |
+
|
46 |
+
[사용자 질문]: {message}
|
47 |
+
[JSON 출력]:
|
48 |
+
"""
|
49 |
+
try:
|
50 |
+
model = genai.GenerativeModel('gemini-1.5-flash', generation_config={"response_mime_type": "application/json"})
|
51 |
+
response = model.generate_content(prompt)
|
52 |
+
return json.loads(response.text)
|
53 |
+
except Exception as e:
|
54 |
+
return {"error": f"LLM 의도 분석 중 오류 발생: {e}"}
|
55 |
+
|
56 |
+
def retrieve_context_from_db(intent: dict) -> str:
|
57 |
+
"""분석된 의도를 바탕으로 데이터베이스에서 정보 검색"""
|
58 |
+
supabase = get_supabase_client()
|
59 |
+
if not supabase:
|
60 |
+
return "데이터베이스에 연결할 수 없습니다."
|
61 |
+
|
62 |
+
if "error" in intent:
|
63 |
+
return f"의도 분석에 실패했습니다: {intent['error']}"
|
64 |
+
|
65 |
+
station_name = intent.get("관측소 이름", "인천")
|
66 |
+
start_time_str = intent.get("시작 시간")
|
67 |
+
end_time_str = intent.get("종료 시간")
|
68 |
+
|
69 |
+
station_id = next((sid for sid, name in STATION_NAMES.items() if name == station_name), "DT_0001")
|
70 |
+
|
71 |
+
if not start_time_str or not end_time_str:
|
72 |
+
return "질문에서 시간 정보를 찾을 수 없습니다."
|
73 |
+
|
74 |
+
try:
|
75 |
+
start_time = date_parser.parse(start_time_str)
|
76 |
+
end_time = date_parser.parse(end_time_str)
|
77 |
+
|
78 |
+
start_query_str = start_time.strftime('%Y-%m-%d %H:%M:%S')
|
79 |
+
end_query_str = end_time.strftime('%Y-%m-%d %H:%M:%S')
|
80 |
+
|
81 |
+
result = supabase.table('tide_predictions')\
|
82 |
+
.select('*')\
|
83 |
+
.eq('station_id', station_id)\
|
84 |
+
.gte('predicted_at', start_query_str)\
|
85 |
+
.lte('predicted_at', end_query_str)\
|
86 |
+
.order('predicted_at')\
|
87 |
+
.execute()
|
88 |
+
|
89 |
+
if result.data:
|
90 |
+
info_text = f"'{station_name}'의 '{start_time_str}'부터 '{end_time_str}'까지 조위 정보입니다.\n\n"
|
91 |
+
|
92 |
+
if len(result.data) > 10:
|
93 |
+
levels = [d['final_tide_level'] for d in result.data]
|
94 |
+
max_level = max(levels)
|
95 |
+
min_level = min(levels)
|
96 |
+
info_text += f"- 최고 조위: {max_level:.1f}cm\n- 최저 조위: {min_level:.1f}cm"
|
97 |
+
else:
|
98 |
+
for d in result.data:
|
99 |
+
time_kst = date_parser.parse(d['predicted_at']).strftime('%H:%M')
|
100 |
+
info_text += f"- {time_kst}: 최종 조위 {d['final_tide_level']:.1f}cm (잔차 {d['predicted_residual']:.1f}cm)\n"
|
101 |
+
return info_text
|
102 |
+
else:
|
103 |
+
return "해당 기간의 예측 데이터를 찾을 수 없습니다. '통합 조위 예측' 탭에서 먼저 예측을 실행해주세요."
|
104 |
+
|
105 |
+
except Exception as e:
|
106 |
+
return f"데이터 검색 중 오류 발생: {traceback.format_exc()}"
|
107 |
+
|
108 |
+
def process_chatbot_query_with_llm(message: str, history: list) -> str:
|
109 |
+
"""최종 RAG 파이프라인"""
|
110 |
+
if not GEMINI_AVAILABLE:
|
111 |
+
return "Gemini API를 사용할 수 없습니다. API 키를 확인하세요."
|
112 |
+
|
113 |
+
intent = parse_intent_with_llm(message)
|
114 |
+
retrieved_data = retrieve__context_from_db(intent)
|
115 |
+
|
116 |
+
prompt = f"""당신은 친절한 해양 ��위 정보 전문가입니다. 주어진 [검색된 데이터]를 바탕으로 사용자의 [질문]에 대해 자연스러운 문장으로 답변해주세요.
|
117 |
+
[검색된 데이터]: {retrieved_data}
|
118 |
+
[사용자 질문]: {message}
|
119 |
+
[답변]:"""
|
120 |
+
|
121 |
+
try:
|
122 |
+
model = genai.GenerativeModel('gemini-1.5-flash')
|
123 |
+
response = model.generate_content(prompt)
|
124 |
+
return response.text
|
125 |
+
except Exception as e:
|
126 |
+
return f"Gemini 답변 생성 중 오류가 발생했습니다: {e}"
|
chatbot_utils.py
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import traceback
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
import pytz
|
6 |
+
from dateutil import parser as date_parser
|
7 |
+
|
8 |
+
from api_utils import api_get_extremes, api_get_current_tide
|
9 |
+
from config import GEMINI_API_KEY, STATION_NAMES
|
10 |
+
from supabase_utils import get_supabase_client
|
11 |
+
|
12 |
+
# Gemini 연동 확인
|
13 |
+
try:
|
14 |
+
import google.generativeai as genai
|
15 |
+
GEMINI_AVAILABLE = True
|
16 |
+
except ImportError:
|
17 |
+
GEMINI_AVAILABLE = False
|
18 |
+
print("Gemini (google-generativeai) 패키지가 설치되지 않았습니다.")
|
19 |
+
|
20 |
+
def parse_intent_with_llm(message: str) -> dict:
|
21 |
+
"""LLM을 사용해 사용자 질문에서 의도를 분석하고 JSON으로 반환"""
|
22 |
+
if not GEMINI_API_KEY:
|
23 |
+
return {"error": "Gemini API 키가 설정되지 않았습니다."}
|
24 |
+
|
25 |
+
prompt = f"""
|
26 |
+
당신은 사용자의 자연어 질문을 분석하여 JSON 객체로 변환하는 전문가입니다.
|
27 |
+
질문에서 '관측소 이름', '원하는 정보', '시작 시간', '종료 시간'을 추출해주세요.
|
28 |
+
현재 시간은 {datetime.now(pytz.timezone('Asia/Seoul')).strftime('%Y-%m-%d %H:%M:%S')} KST 입니다.
|
29 |
+
|
30 |
+
- '원하는 정보'는 '특정 시간 조위' 또는 '구간 조위' 중 하나여야 합니다.
|
31 |
+
- '시작 시간'과 '종료 시간'은 'YYYY-MM-DD HH:MM:SS' 형식으로 변환해주세요.
|
32 |
+
- 단일 시간이면 시작과 종료 시간을 동일하게 설정하고, 구간이면 그에 맞게 설정하세요.
|
33 |
+
- 관측소 이름이 없으면 '인천'을 기본값으로 사용하세요.
|
34 |
+
|
35 |
+
[사용자 질문]: {message}
|
36 |
+
[JSON 출력]:
|
37 |
+
"""
|
38 |
+
try:
|
39 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
40 |
+
model = genai.GenerativeModel('gemini-1.5-flash', generation_config={"response_mime_type": "application/json"})
|
41 |
+
response = model.generate_content(prompt)
|
42 |
+
return json.loads(response.text)
|
43 |
+
except Exception as e:
|
44 |
+
return {"error": f"LLM 의도 분석 중 오류 발생: {e}"}
|
45 |
+
|
46 |
+
def retrieve_context_from_db(intent: dict) -> str:
|
47 |
+
"""분석된 의도를 바탕으로 데이터베이스에서 정보 검색"""
|
48 |
+
supabase = get_supabase_client()
|
49 |
+
if not supabase:
|
50 |
+
return "데이터베이스에 연결할 수 없습니다."
|
51 |
+
|
52 |
+
if "error" in intent:
|
53 |
+
return f"의도 분석에 실패했습니다: {intent['error']}"
|
54 |
+
|
55 |
+
station_name = intent.get("관측소 이름", "인천")
|
56 |
+
start_time_str = intent.get("시작 시간")
|
57 |
+
end_time_str = intent.get("종료 시간")
|
58 |
+
|
59 |
+
station_id = next((sid for sid, name in STATION_NAMES.items() if name == station_name), "DT_0001")
|
60 |
+
|
61 |
+
if not start_time_str or not end_time_str:
|
62 |
+
return "질문에서 시간 정보를 찾을 수 없습니다."
|
63 |
+
|
64 |
+
try:
|
65 |
+
start_time = date_parser.parse(start_time_str)
|
66 |
+
end_time = date_parser.parse(end_time_str)
|
67 |
+
|
68 |
+
start_query_str = start_time.strftime('%Y-%m-%d %H:%M:%S')
|
69 |
+
end_query_str = end_time.strftime('%Y-%m-%d %H:%M:%S')
|
70 |
+
|
71 |
+
result = supabase.table('tide_predictions')\
|
72 |
+
.select('*')\
|
73 |
+
.eq('station_id', station_id)\
|
74 |
+
.gte('predicted_at', start_query_str)\
|
75 |
+
.lte('predicted_at', end_query_str)\
|
76 |
+
.order('predicted_at')\
|
77 |
+
.execute()
|
78 |
+
|
79 |
+
if result.data:
|
80 |
+
info_text = f"'{station_name}'의 '{start_time_str}'부터 '{end_time_str}'까지 조위 정보입니다.\n\n"
|
81 |
+
|
82 |
+
if len(result.data) > 10:
|
83 |
+
levels = [d['final_tide_level'] for d in result.data]
|
84 |
+
max_level = max(levels)
|
85 |
+
min_level = min(levels)
|
86 |
+
info_text += f"- 최고 조위: {max_level:.1f}cm\n- 최저 조위: {min_level:.1f}cm"
|
87 |
+
else:
|
88 |
+
for d in result.data:
|
89 |
+
time_kst = date_parser.parse(d['predicted_at']).strftime('%H:%M')
|
90 |
+
info_text += f"- {time_kst}: 최종 조위 {d['final_tide_level']:.1f}cm (잔차 {d['predicted_residual']:.1f}cm)\n"
|
91 |
+
return info_text
|
92 |
+
else:
|
93 |
+
return "해당 기간의 예측 데이터를 찾을 수 없습니다. '통합 조위 예측' 탭에서 먼저 예측을 실행해주세요."
|
94 |
+
|
95 |
+
except Exception as e:
|
96 |
+
return f"데이터 검색 중 오류 발생: {traceback.format_exc()}"
|
97 |
+
|
98 |
+
def process_chatbot_query_with_llm(message: str, history: list) -> str:
|
99 |
+
"""최종 RAG 파이프라인"""
|
100 |
+
intent = parse_intent_with_llm(message)
|
101 |
+
retrieved_data = retrieve_context_from_db(intent)
|
102 |
+
|
103 |
+
prompt = f"""당신은 친절한 해양 조위 정보 전문가입니다. 주어진 [검색된 데이터]를 바탕으로 사용자의 [질문]에 대해 자연스러운 문장으로 답변해주세요.
|
104 |
+
[검색된 데이터]: {retrieved_data}
|
105 |
+
[사용자 질문]: {message}
|
106 |
+
[답변]:"""
|
107 |
+
|
108 |
+
try:
|
109 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
110 |
+
model = genai.GenerativeModel('gemini-1.5-flash')
|
111 |
+
response = model.generate_content(prompt)
|
112 |
+
return response.text
|
113 |
+
except Exception as e:
|
114 |
+
return f"Gemini 답변 생성 중 오류가 발생했습니다: {e}"
|
config.py
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
|
3 |
+
# --- 0. 설정 ---
|
4 |
+
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
5 |
+
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
|
6 |
+
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
7 |
+
|
8 |
+
STATIONS = [
|
9 |
+
"DT_0001", "DT_0065", "DT_0008", "DT_0067", "DT_0043", "DT_0002",
|
10 |
+
"DT_0050", "DT_0017", "DT_0052", "DT_0025", "DT_0051", "DT_0037",
|
11 |
+
"DT_0024", "DT_0018", "DT_0068", "DT_0003", "DT_0066"
|
12 |
+
]
|
13 |
+
|
14 |
+
STATION_NAMES = {
|
15 |
+
"DT_0001": "인천", "DT_0002": "평택", "DT_0003": "영광", "DT_0008": "안산",
|
16 |
+
"DT_0017": "대산", "DT_0018": "군산", "DT_0024": "장항", "DT_0025": "보령",
|
17 |
+
"DT_0037": "어청도", "DT_0043": "영흥도", "DT_0050": "태안", "DT_0051": "서천마량",
|
18 |
+
"DT_0052": "인천송도", "DT_0065": "덕적도", "DT_0066": "향화도", "DT_0067": "안흥",
|
19 |
+
"DT_0068": "위도"
|
20 |
+
}
|
prediction.py
ADDED
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import subprocess
|
3 |
+
import traceback
|
4 |
+
from datetime import datetime, timedelta
|
5 |
+
|
6 |
+
import gradio as gr
|
7 |
+
import numpy as np
|
8 |
+
import pandas as pd
|
9 |
+
import plotly.graph_objects as go
|
10 |
+
import pytz
|
11 |
+
|
12 |
+
from config import STATION_NAMES
|
13 |
+
from supabase_utils import (
|
14 |
+
get_harmonic_predictions, save_predictions_to_supabase, get_supabase_client
|
15 |
+
)
|
16 |
+
|
17 |
+
def get_common_args(station_id):
|
18 |
+
return [
|
19 |
+
"--model", "TimeXer", "--features", "MS", "--seq_len", "144", "--pred_len", "72",
|
20 |
+
"--label_len", "96", "--enc_in", "5", "--dec_in", "5", "--c_out", "1",
|
21 |
+
"--d_model", "256", "--d_ff", "512", "--n_heads", "8", "--e_layers", "1",
|
22 |
+
"--d_layers", "1", "--factor", "3", "--patch_len", "16", "--expand", "2", "--d_conv", "4"
|
23 |
+
]
|
24 |
+
|
25 |
+
def validate_csv_file(file_path, required_rows=144):
|
26 |
+
"""CSV 파일 유효성 검사"""
|
27 |
+
try:
|
28 |
+
df = pd.read_csv(file_path)
|
29 |
+
required_columns = ['date', 'air_pres', 'wind_dir', 'wind_speed', 'air_temp', 'residual']
|
30 |
+
missing_columns = [col for col in required_columns if col not in df.columns]
|
31 |
+
|
32 |
+
if missing_columns:
|
33 |
+
return False, f"필수 컬럼이 누락되었습니다: {missing_columns}"
|
34 |
+
|
35 |
+
if len(df) < required_rows:
|
36 |
+
return False, f"데이터가 부족합니다. 최소 {required_rows}행 필요, 현재 {len(df)}행"
|
37 |
+
|
38 |
+
return True, "파일이 유효합니다."
|
39 |
+
except Exception as e:
|
40 |
+
return False, f"파일 읽기 오류: {str(e)}"
|
41 |
+
|
42 |
+
def execute_inference_and_get_results(command):
|
43 |
+
"""inference 실행하고 결과 파일을 읽어서 반환"""
|
44 |
+
try:
|
45 |
+
print(f"실행 명령어: {' '.join(command)}")
|
46 |
+
result = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
47 |
+
|
48 |
+
if result.returncode != 0:
|
49 |
+
error_message = (
|
50 |
+
f"실행 실패 (Exit Code: {result.returncode}):\n\n"
|
51 |
+
f"--- 에러 로그 ---\n{result.stderr}\n\n"
|
52 |
+
f"--- 일반 출력 ---\n{result.stdout}"
|
53 |
+
)
|
54 |
+
raise gr.Error(error_message)
|
55 |
+
|
56 |
+
return True, result.stdout
|
57 |
+
except subprocess.TimeoutExpired:
|
58 |
+
raise gr.Error("실행 시간이 초과되었습니다. (5분 제한)")
|
59 |
+
except Exception as e:
|
60 |
+
raise gr.Error(f"내부 오류: {str(e)}")
|
61 |
+
|
62 |
+
def calculate_final_tide(residual_predictions, station_id, last_time):
|
63 |
+
"""잔차 예측 + 조화 예측 = 최종 조위 계산"""
|
64 |
+
if isinstance(last_time, pd.Timestamp):
|
65 |
+
last_time = last_time.to_pydatetime()
|
66 |
+
|
67 |
+
kst = pytz.timezone('Asia/Seoul')
|
68 |
+
if last_time.tzinfo is None:
|
69 |
+
last_time = kst.localize(last_time)
|
70 |
+
|
71 |
+
start_time = last_time + timedelta(minutes=5)
|
72 |
+
end_time = last_time + timedelta(minutes=72*5)
|
73 |
+
|
74 |
+
harmonic_data = get_harmonic_predictions(station_id, start_time, end_time)
|
75 |
+
|
76 |
+
residual_flat = residual_predictions.flatten()
|
77 |
+
num_points = len(residual_flat)
|
78 |
+
|
79 |
+
if not harmonic_data:
|
80 |
+
print("조화 예측 데이터를 찾을 수 없습니다. 잔차 예측만 반환합니다.")
|
81 |
+
return {
|
82 |
+
'times': [last_time + timedelta(minutes=(i+1)*5) for i in range(num_points)],
|
83 |
+
'residual': residual_flat.tolist(),
|
84 |
+
'harmonic': [0.0] * num_points,
|
85 |
+
'final_tide': residual_flat.tolist()
|
86 |
+
}
|
87 |
+
|
88 |
+
final_results = {
|
89 |
+
'times': [],
|
90 |
+
'residual': [],
|
91 |
+
'harmonic': [],
|
92 |
+
'final_tide': []
|
93 |
+
}
|
94 |
+
|
95 |
+
harmonic_dict = {}
|
96 |
+
for h_data in harmonic_data:
|
97 |
+
h_time_str = h_data['predicted_at']
|
98 |
+
|
99 |
+
try:
|
100 |
+
if 'T' in h_time_str:
|
101 |
+
if h_time_str.endswith('Z'):
|
102 |
+
h_time = datetime.fromisoformat(h_time_str[:-1] + '+00:00')
|
103 |
+
elif '+' in h_time_str or '-' in h_time_str[-6:]:
|
104 |
+
h_time = datetime.fromisoformat(h_time_str)
|
105 |
+
else:
|
106 |
+
h_time = datetime.fromisoformat(h_time_str + '+00:00')
|
107 |
+
else:
|
108 |
+
from dateutil import parser
|
109 |
+
h_time = parser.parse(h_time_str)
|
110 |
+
|
111 |
+
if h_time.tzinfo is None:
|
112 |
+
h_time = pytz.UTC.localize(h_time)
|
113 |
+
h_time = h_time.astimezone(kst)
|
114 |
+
|
115 |
+
except Exception as e:
|
116 |
+
print(f"시간 파싱 오류: {h_time_str}, {e}")
|
117 |
+
continue
|
118 |
+
|
119 |
+
minutes = (h_time.minute // 5) * 5
|
120 |
+
h_time = h_time.replace(minute=minutes, second=0, microsecond=0)
|
121 |
+
harmonic_value = float(h_data['harmonic_level'])
|
122 |
+
harmonic_dict[h_time] = harmonic_value
|
123 |
+
|
124 |
+
for i, residual in enumerate(residual_flat):
|
125 |
+
pred_time = last_time + timedelta(minutes=(i+1)*5)
|
126 |
+
pred_time = pred_time.replace(second=0, microsecond=0)
|
127 |
+
|
128 |
+
harmonic_value = harmonic_dict.get(pred_time, 0.0)
|
129 |
+
|
130 |
+
if harmonic_value == 0.0 and harmonic_dict:
|
131 |
+
min_diff = float('inf')
|
132 |
+
for h_time, h_val in harmonic_dict.items():
|
133 |
+
diff = abs((h_time - pred_time).total_seconds())
|
134 |
+
if diff < min_diff and diff < 300:
|
135 |
+
min_diff = diff
|
136 |
+
harmonic_value = h_val
|
137 |
+
|
138 |
+
final_tide = float(residual) + harmonic_value
|
139 |
+
|
140 |
+
final_results['times'].append(pred_time)
|
141 |
+
final_results['residual'].append(float(residual))
|
142 |
+
final_results['harmonic'].append(harmonic_value)
|
143 |
+
final_results['final_tide'].append(final_tide)
|
144 |
+
|
145 |
+
return final_results
|
146 |
+
|
147 |
+
def create_enhanced_prediction_plot(prediction_results, input_data, station_name):
|
148 |
+
"""잔차 + 조화 + 최종 조위를 모두 표시하는 향상된 플롯"""
|
149 |
+
try:
|
150 |
+
input_df = pd.read_csv(input_data.name)
|
151 |
+
input_df['date'] = pd.to_datetime(input_df['date'])
|
152 |
+
|
153 |
+
recent_data = input_df.tail(24)
|
154 |
+
future_times = pd.to_datetime(prediction_results['times'])
|
155 |
+
|
156 |
+
fig = go.Figure()
|
157 |
+
|
158 |
+
fig.add_trace(go.Scatter(
|
159 |
+
x=recent_data['date'],
|
160 |
+
y=recent_data['residual'],
|
161 |
+
mode='lines+markers',
|
162 |
+
name='실제 잔차조위',
|
163 |
+
line=dict(color='blue', width=2),
|
164 |
+
marker=dict(size=4)
|
165 |
+
))
|
166 |
+
|
167 |
+
fig.add_trace(go.Scatter(
|
168 |
+
x=future_times,
|
169 |
+
y=prediction_results['residual'],
|
170 |
+
mode='lines+markers',
|
171 |
+
name='잔차 예측',
|
172 |
+
line=dict(color='red', width=2, dash='dash'),
|
173 |
+
marker=dict(size=3)
|
174 |
+
))
|
175 |
+
|
176 |
+
if any(h != 0 for h in prediction_results['harmonic']):
|
177 |
+
fig.add_trace(go.Scatter(
|
178 |
+
x=future_times,
|
179 |
+
y=prediction_results['harmonic'],
|
180 |
+
mode='lines',
|
181 |
+
name='조화 예측',
|
182 |
+
line=dict(color='orange', width=2)
|
183 |
+
))
|
184 |
+
|
185 |
+
fig.add_trace(go.Scatter(
|
186 |
+
x=future_times,
|
187 |
+
y=prediction_results['final_tide'],
|
188 |
+
mode='lines+markers',
|
189 |
+
name='최종 조위',
|
190 |
+
line=dict(color='green', width=3),
|
191 |
+
marker=dict(size=4)
|
192 |
+
))
|
193 |
+
|
194 |
+
last_time = recent_data['date'].iloc[-1]
|
195 |
+
|
196 |
+
fig.add_annotation(
|
197 |
+
x=last_time,
|
198 |
+
y=0,
|
199 |
+
text="← 과거 | 미래 →",
|
200 |
+
showarrow=False,
|
201 |
+
yref="paper",
|
202 |
+
yshift=10,
|
203 |
+
font=dict(size=12, color="gray")
|
204 |
+
)
|
205 |
+
|
206 |
+
fig.update_layout(
|
207 |
+
title=f'{station_name} 통합 조위 예측 결과',
|
208 |
+
xaxis_title='시간',
|
209 |
+
yaxis_title='수위 (cm)',
|
210 |
+
hovermode='x unified',
|
211 |
+
height=600,
|
212 |
+
showlegend=True,
|
213 |
+
xaxis=dict(tickformat='%H:%M<br>%m/%d', gridcolor='lightgray', showgrid=True),
|
214 |
+
yaxis=dict(gridcolor='lightgray', showgrid=True),
|
215 |
+
plot_bgcolor='white'
|
216 |
+
)
|
217 |
+
|
218 |
+
return fig
|
219 |
+
except Exception as e:
|
220 |
+
print(f"Enhanced plot creation error: {e}")
|
221 |
+
traceback.print_exc()
|
222 |
+
fig = go.Figure()
|
223 |
+
fig.add_annotation(
|
224 |
+
text=f"시각화 생성 중 오류: {str(e)}",
|
225 |
+
xref="paper", yref="paper",
|
226 |
+
x=0.5, y=0.5, showarrow=False
|
227 |
+
)
|
228 |
+
return fig
|
229 |
+
|
230 |
+
def single_prediction(station_id, input_csv_file):
|
231 |
+
if input_csv_file is None:
|
232 |
+
raise gr.Error("예측을 위한 입력 파일을 업로드해주세요.")
|
233 |
+
|
234 |
+
is_valid, message = validate_csv_file(input_csv_file.name)
|
235 |
+
if not is_valid:
|
236 |
+
raise gr.Error(f"파일 오류: {message}")
|
237 |
+
|
238 |
+
station_name = STATION_NAMES.get(station_id, station_id)
|
239 |
+
|
240 |
+
common_args = get_common_args(station_id)
|
241 |
+
setting_name = f"long_term_forecast_{station_id}_144_72_TimeXer_TIDE_ftMS_sl144_ll96_pl72_dm256_nh8_el1_dl1_df512_expand2_dc4_fc3_ebtimeF_dtTrue_Exp_0"
|
242 |
+
checkpoint_path = f"./checkpoints/{setting_name}/checkpoint.pth"
|
243 |
+
scaler_path = f"./checkpoints/{setting_name}/scaler.gz"
|
244 |
+
|
245 |
+
if not os.path.exists(checkpoint_path):
|
246 |
+
raise gr.Error(f"모델 파일을 찾을 수 없습니다: {checkpoint_path}")
|
247 |
+
if not os.path.exists(scaler_path):
|
248 |
+
raise gr.Error(f"스케일러 파일을 찾을 수 없습니다: {scaler_path}")
|
249 |
+
|
250 |
+
command = ["python", "inference.py",
|
251 |
+
"--checkpoint_path", checkpoint_path,
|
252 |
+
"--scaler_path", scaler_path,
|
253 |
+
"--predict_input_file", input_csv_file.name] + common_args
|
254 |
+
|
255 |
+
gr.Info(f"{station_name}({station_id}) 통합 조위 예측을 실행중입니다...")
|
256 |
+
|
257 |
+
success, output = execute_inference_and_get_results(command)
|
258 |
+
|
259 |
+
try:
|
260 |
+
prediction_file = "pred_results/prediction_future.npy"
|
261 |
+
if os.path.exists(prediction_file):
|
262 |
+
residual_predictions = np.load(prediction_file)
|
263 |
+
|
264 |
+
input_df = pd.read_csv(input_csv_file.name)
|
265 |
+
input_df['date'] = pd.to_datetime(input_df['date'])
|
266 |
+
last_time = input_df['date'].iloc[-1]
|
267 |
+
|
268 |
+
prediction_results = calculate_final_tide(residual_predictions, station_id, last_time)
|
269 |
+
plot = create_enhanced_prediction_plot(prediction_results, input_csv_file, station_name)
|
270 |
+
|
271 |
+
has_harmonic = any(h != 0 for h in prediction_results['harmonic'])
|
272 |
+
|
273 |
+
if has_harmonic:
|
274 |
+
result_df = pd.DataFrame({
|
275 |
+
'예측 시간': [t.strftime('%Y-%m-%d %H:%M') for t in prediction_results['times']],
|
276 |
+
'잔차 예측 (cm)': [f"{val:.2f}" for val in prediction_results['residual']],
|
277 |
+
'조화 예측 (cm)': [f"{val:.2f}" for val in prediction_results['harmonic']],
|
278 |
+
'최종 조위 (cm)': [f"{val:.2f}" for val in prediction_results['final_tide']]
|
279 |
+
})
|
280 |
+
else:
|
281 |
+
result_df = pd.DataFrame({
|
282 |
+
'예측 시간': [t.strftime('%Y-%m-%d %H:%M') for t in prediction_results['times']],
|
283 |
+
'잔차 예측 (cm)': [f"{val:.2f}" for val in prediction_results['residual']]
|
284 |
+
})
|
285 |
+
|
286 |
+
saved_count = save_predictions_to_supabase(station_id, prediction_results)
|
287 |
+
if saved_count > 0:
|
288 |
+
save_message = f"\n💾 Supabase에 {saved_count}개 예측 결과 저장 완료!"
|
289 |
+
elif get_supabase_client() is None:
|
290 |
+
save_message = "\n⚠️ Supabase 연결 실패 (환경변수 확인 필요)"
|
291 |
+
else:
|
292 |
+
save_message = "\n⚠️ Supabase 저장 실패"
|
293 |
+
|
294 |
+
return plot, result_df, f"✅ 예측 완료!{save_message}\n\n{output}"
|
295 |
+
else:
|
296 |
+
return None, None, f"❌ 결과 파일을 찾을 수 없습니다.\n\n{output}"
|
297 |
+
except Exception as e:
|
298 |
+
print(f"Result processing error: {e}")
|
299 |
+
traceback.print_exc()
|
300 |
+
return None, None, f"❌ 결과 처리 중 오류: {str(e)}\n\n{output}"
|
supabase_utils.py
ADDED
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import re
|
3 |
+
import traceback
|
4 |
+
from datetime import datetime
|
5 |
+
|
6 |
+
import pandas as pd
|
7 |
+
import pytz
|
8 |
+
from dateutil import parser as date_parser
|
9 |
+
|
10 |
+
from config import SUPABASE_URL, SUPABASE_KEY
|
11 |
+
|
12 |
+
# Supabase 연동 추가
|
13 |
+
try:
|
14 |
+
from supabase import create_client, Client
|
15 |
+
SUPABASE_AVAILABLE = True
|
16 |
+
except ImportError:
|
17 |
+
SUPABASE_AVAILABLE = False
|
18 |
+
print("Supabase 패키지가 설치되지 않았습니다.")
|
19 |
+
|
20 |
+
def clean_string(s):
|
21 |
+
"""문자열에서 특수 유니코드 문자 제거"""
|
22 |
+
if s is None:
|
23 |
+
return None
|
24 |
+
cleaned = s.replace('\u2028', '').replace('\u2029', '')
|
25 |
+
cleaned = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned)
|
26 |
+
return cleaned.strip()
|
27 |
+
|
28 |
+
def get_supabase_client():
|
29 |
+
"""Supabase 클라이언트 생성"""
|
30 |
+
if not SUPABASE_AVAILABLE:
|
31 |
+
return None
|
32 |
+
|
33 |
+
try:
|
34 |
+
if not SUPABASE_URL or not SUPABASE_KEY:
|
35 |
+
print("Supabase 환경변수가 설정되지 않았습니다.")
|
36 |
+
return None
|
37 |
+
|
38 |
+
url = clean_string(SUPABASE_URL)
|
39 |
+
key = clean_string(SUPABASE_KEY)
|
40 |
+
|
41 |
+
if not url.startswith('http'):
|
42 |
+
print(f"잘못된 SUPABASE_URL 형식: {url}")
|
43 |
+
return None
|
44 |
+
|
45 |
+
return create_client(url, key)
|
46 |
+
except Exception as e:
|
47 |
+
print(f"Supabase 연결 오류: {e}")
|
48 |
+
traceback.print_exc()
|
49 |
+
return None
|
50 |
+
|
51 |
+
def get_harmonic_predictions(station_id, start_time, end_time):
|
52 |
+
"""해당 시간 범위의 조화 예측값 조회"""
|
53 |
+
supabase = get_supabase_client()
|
54 |
+
if not supabase:
|
55 |
+
print("Supabase 클라이언트를 생성할 수 없습니다.")
|
56 |
+
return []
|
57 |
+
|
58 |
+
try:
|
59 |
+
kst = pytz.timezone('Asia/Seoul')
|
60 |
+
|
61 |
+
if start_time.tzinfo is None:
|
62 |
+
start_time = kst.localize(start_time)
|
63 |
+
if end_time.tzinfo is None:
|
64 |
+
end_time = kst.localize(end_time)
|
65 |
+
|
66 |
+
start_utc = start_time.astimezone(pytz.UTC)
|
67 |
+
end_utc = end_time.astimezone(pytz.UTC)
|
68 |
+
|
69 |
+
start_str = start_utc.strftime('%Y-%m-%dT%H:%M:%S+00:00')
|
70 |
+
end_str = end_utc.strftime('%Y-%m-%dT%H:%M:%S+00:00')
|
71 |
+
|
72 |
+
result = supabase.table('harmonic_predictions')\
|
73 |
+
.select('predicted_at, harmonic_level')\
|
74 |
+
.eq('station_id', station_id)\
|
75 |
+
.gte('predicted_at', start_str)\
|
76 |
+
.lte('predicted_at', end_str)\
|
77 |
+
.order('predicted_at')\
|
78 |
+
.limit(1000)\
|
79 |
+
.execute()
|
80 |
+
|
81 |
+
return result.data if result.data else []
|
82 |
+
except Exception as e:
|
83 |
+
print(f"조화 예측값 조회 오류: {e}")
|
84 |
+
traceback.print_exc()
|
85 |
+
return []
|
86 |
+
|
87 |
+
def save_predictions_to_supabase(station_id, prediction_results):
|
88 |
+
"""예측 결과를 Supabase에 저장"""
|
89 |
+
supabase = get_supabase_client()
|
90 |
+
if not supabase:
|
91 |
+
print("Supabase 클라이언트를 생성할 수 없습니다.")
|
92 |
+
return 0
|
93 |
+
|
94 |
+
try:
|
95 |
+
if prediction_results['times']:
|
96 |
+
start_time = prediction_results['times'][0].strftime('%Y-%m-%dT%H:%M:%S')
|
97 |
+
end_time = prediction_results['times'][-1].strftime('%Y-%m-%dT%H:%M:%S')
|
98 |
+
|
99 |
+
supabase.table('tide_predictions')\
|
100 |
+
.delete()\
|
101 |
+
.eq('station_id', station_id)\
|
102 |
+
.gte('predicted_at', start_time)\
|
103 |
+
.lte('predicted_at', end_time)\
|
104 |
+
.execute()
|
105 |
+
|
106 |
+
insert_data = []
|
107 |
+
for i in range(len(prediction_results['times'])):
|
108 |
+
time_str = prediction_results['times'][i].strftime('%Y-%m-%dT%H:%M:%S')
|
109 |
+
|
110 |
+
insert_data.append({
|
111 |
+
'station_id': station_id,
|
112 |
+
'predicted_at': time_str,
|
113 |
+
'predicted_residual': float(prediction_results['residual'][i]),
|
114 |
+
'harmonic_level': float(prediction_results['harmonic'][i]),
|
115 |
+
'final_tide_level': float(prediction_results['final_tide'][i])
|
116 |
+
})
|
117 |
+
|
118 |
+
result = supabase.table('tide_predictions')\
|
119 |
+
.insert(insert_data)\
|
120 |
+
.execute()
|
121 |
+
|
122 |
+
return len(insert_data)
|
123 |
+
except Exception as e:
|
124 |
+
print(f"예측 결과 저장 오류: {e}")
|
125 |
+
traceback.print_exc()
|
126 |
+
return 0
|
ui.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import os
|
3 |
+
from config import STATIONS
|
4 |
+
from supabase_utils import get_supabase_client
|
5 |
+
|
6 |
+
def create_ui(prediction_handler, chatbot_handler):
|
7 |
+
"""Gradio UI를 생성하고 반환합니다."""
|
8 |
+
with gr.Blocks(title="통합 조위 예측 시스템", theme=gr.themes.Soft()) as demo:
|
9 |
+
gr.Markdown("# 🌊 통합 조위 예측 시스템 with Gemini")
|
10 |
+
|
11 |
+
# 연결 상태 표시
|
12 |
+
client = get_supabase_client()
|
13 |
+
supabase_status = "🟢 연결됨" if client else "🔴 연결 안됨 (환경변수 확인 필요)"
|
14 |
+
gemini_status = "🟢 연결됨" if os.getenv("GEMINI_API_KEY") else "🔴 연결 안됨 (환경변수 확인 필요)"
|
15 |
+
gr.Markdown(f"**Supabase 상태**: {supabase_status} | **Gemini 상태**: {gemini_status}")
|
16 |
+
|
17 |
+
with gr.Tabs():
|
18 |
+
with gr.TabItem("통합 조위 예측"):
|
19 |
+
with gr.Row():
|
20 |
+
with gr.Column(scale=1):
|
21 |
+
station_id_input = gr.Dropdown(STATIONS, label="관측소 선택", value="DT_0001")
|
22 |
+
input_csv = gr.File(label="과거 데이터 업로드 (.csv)")
|
23 |
+
predict_btn = gr.Button("예측 실행", variant="primary")
|
24 |
+
with gr.Column(scale=3):
|
25 |
+
output_plot = gr.Plot(label="예측 결과 시각화")
|
26 |
+
output_df = gr.DataFrame(label="예측 결과 데이터")
|
27 |
+
output_log = gr.Textbox(label="실행 로그", lines=5, interactive=False)
|
28 |
+
|
29 |
+
with gr.TabItem("AI 조위 챗봇"):
|
30 |
+
gr.ChatInterface(
|
31 |
+
fn=chatbot_handler,
|
32 |
+
title="AI 조위 챗봇",
|
33 |
+
description="조위에 대해 궁금한 점을 물어보세요. (예: '인천 오늘 현재 조위 알려줘')",
|
34 |
+
#examples=[]
|
35 |
+
)
|
36 |
+
|
37 |
+
predict_btn.click(
|
38 |
+
fn=prediction_handler,
|
39 |
+
inputs=[station_id_input, input_csv],
|
40 |
+
outputs=[output_plot, output_df, output_log]
|
41 |
+
)
|
42 |
+
return demo
|