alwaysgood commited on
Commit
9241a3b
·
verified ·
1 Parent(s): 9b664b6

Update api_utils.py

Browse files
Files changed (1) hide show
  1. api_utils.py +566 -132
api_utils.py CHANGED
@@ -1,152 +1,586 @@
 
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
- import logging
7
  from supabase_utils import get_supabase_client
8
  from config import STATION_NAMES
9
 
10
- # Basic logging configuration
11
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- def fetch_tide_data(station_id, start_utc, end_utc, table='historical_tide'):
14
- """Fetches data from a specified Supabase table within a date range."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  supabase = get_supabase_client()
16
  if not supabase:
17
- logging.error("Failed to create a Supabase client.")
18
- raise ValueError("Supabase 클라이언트를 생성할 수 없습니다.")
19
-
20
  try:
21
- query_column = 'observed_at' if table == 'historical_tide' else 'predicted_at'
22
- select_columns = 'observed_at, tide_level' if table == 'historical_tide' else 'predicted_at, final_tide_level'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- result = supabase.table(table) \
25
- .select(select_columns) \
26
- .eq('station_id', station_id) \
27
- .gte(query_column, start_utc) \
28
- .lte(query_column, end_utc) \
29
- .order(query_column) \
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  .execute()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  if not result.data:
33
- logging.warning(f"No data found for station {station_id} from {start_utc} to {end_utc} in table '{table}'.")
34
- return pd.DataFrame() # Return empty DataFrame for robustness
35
-
36
- return pd.DataFrame(result.data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  except Exception as e:
38
- logging.error(f"Error fetching data for station {station_id}: {e}", exc_info=True)
39
- raise ValueError(f"데이터 조회 오류: {e}")
40
 
41
- def get_tide_data(station_id, start_date=None, end_date=None, include_extremes=False, return_plot=False):
 
 
 
 
 
 
42
  """
43
- Retrieves and processes tide data, optionally including tide extremes and a plot.
44
-
45
- :param station_id: The station identifier.
46
- :param start_date: Start date in 'YYYY-MM-DD' format. Defaults to today.
47
- :param end_date: End date in 'YYYY-MM-DD' format. Defaults to the start date.
48
- :param include_extremes: Whether to calculate and include tidal extremes (high/low tides).
49
- :param return_plot: Whether to generate and return a Plotly figure.
50
- :return: A dictionary containing the data, and optionally extremes and a plot.
 
 
51
  """
52
- # Default to today (in Seoul timezone) if start_date is not provided.
53
- start_date = start_date or datetime.now(pytz.timezone('Asia/Seoul')).strftime('%Y-%m-%d')
54
- start_time = pytz.timezone('Asia/Seoul').localize(datetime.strptime(start_date, '%Y-%m-%d'))
55
-
56
- # The query period ends 24 hours after the start of the end_date.
57
- # If no end_date is given, the period is 24 hours from the start_time.
58
- end_time = start_time + timedelta(hours=24) if not end_date else \
59
- pytz.timezone('Asia/Seoul').localize(datetime.strptime(end_date, '%Y-%m-%d')) + timedelta(hours=24)
60
-
61
- # Convert local time to UTC for the database query.
62
- start_utc = start_time.astimezone(pytz.UTC).isoformat()
63
- end_utc = end_time.astimezone(pytz.UTC).isoformat()
64
-
65
- df = fetch_tide_data(station_id, start_utc, end_utc)
66
- if df.empty:
67
- logging.warning(f"No tide data available for station {station_id} for the selected period.")
68
- return {"data": pd.DataFrame(), "extremes": pd.DataFrame(), "plot": go.Figure()}
69
-
70
- df['observed_at'] = pd.to_datetime(df['observed_at']).dt.tz_convert('Asia/Seoul')
71
- df['tide_level'] = pd.to_numeric(df['tide_level'])
72
-
73
- result = {"data": df}
74
-
75
- if include_extremes:
76
- df['min'] = df.tide_level[(df.tide_level.shift(1) > df.tide_level) & (df.tide_level.shift(-1) > df.tide_level)]
77
- df['max'] = df.tide_level[(df.tide_level.shift(1) < df.tide_level) & (df.tide_level.shift(-1) < df.tide_level)]
78
- extremes_df = df.dropna(subset=['min', 'max'], how='all').copy()
79
- extremes_df['type'] = extremes_df.apply(lambda row: 'High Tide' if pd.notna(row['max']) else 'Low Tide', axis=1)
80
- extremes_df['value'] = extremes_df.apply(lambda row: row['max'] if pd.notna(row['max']) else row['min'], axis=1)
81
- extremes_df['time'] = extremes_df['observed_at'].dt.strftime('%H:%M')
82
- result["extremes"] = extremes_df[['time', 'type', 'value']]
83
-
84
- if return_plot:
85
- fig = go.Figure()
86
- fig.add_trace(go.Scatter(x=df['observed_at'], y=df['tide_level'], mode='lines',
87
- name=f'{STATION_NAMES.get(station_id, station_id)} Tide'))
88
- fig.update_layout(
89
- title=f'{STATION_NAMES.get(station_id, station_id)} Tide: {start_date} to {end_date or start_date}',
90
- xaxis_title='Time', yaxis_title='Tide Level (cm)', height=400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  )
92
- result["plot"] = fig
93
-
94
- return result
95
-
96
- def compare_tide_patterns(station_id, date1, date2, time_window=24):
97
- """두 날짜의 조위 패턴 비교"""
98
- start1 = pytz.timezone('Asia/Seoul').localize(datetime.strptime(date1, '%Y-%m-%d'))
99
- start2 = pytz.timezone('Asia/Seoul').localize(datetime.strptime(date2, '%Y-%m-%d'))
100
- end1 = start1 + timedelta(hours=time_window)
101
- end2 = start2 + timedelta(hours=time_window)
102
-
103
- df1 = fetch_tide_data(station_id, start1.astimezone(pytz.UTC).isoformat(), end1.astimezone(pytz.UTC).isoformat())
104
- df2 = fetch_tide_data(station_id, start2.astimezone(pytz.UTC).isoformat(), end2.astimezone(pytz.UTC).isoformat())
105
-
106
- df1['minutes_from_start'] = (pd.to_datetime(df1['observed_at']) - pd.to_datetime(df1['observed_at']).iloc[0]).dt.total_seconds() / 60
107
- df2['minutes_from_start'] = (pd.to_datetime(df2['observed_at']) - pd.to_datetime(df2['observed_at']).iloc[0]).dt.total_seconds() / 60
108
-
109
- fig = go.Figure()
110
- fig.add_trace(go.Scatter(x=df1['minutes_from_start'], y=df1['tide_level'], mode='lines', name=date1))
111
- fig.add_trace(go.Scatter(x=df2['minutes_from_start'], y=df2['tide_level'], mode='lines', name=date2))
112
- fig.update_layout(
113
- title=f'{STATION_NAMES.get(station_id, station_id)} Tide Comparison: {date1} vs {date2}',
114
- xaxis_title='Minutes from Midnight', yaxis_title='Tide Level (cm)', height=400
115
- )
116
- return {"data": [df1, df2], "plot": fig}
117
-
118
- def get_tide_summary(station_id, year, month, summary_type='monthly'):
119
- """월간/연간 조위 요약"""
120
- start_date = f"{year}-{int(month):02d}-01"
121
- end_date = (datetime.strptime(start_date, '%Y-%m-%d') + pd.offsets.MonthEnd(1)).strftime('%Y-%m-%d')
122
-
123
- df = fetch_tide_data(station_id,
124
- pytz.timezone('Asia/Seoul').localize(datetime.strptime(start_date, '%Y-%m-%d')).astimezone(pytz.UTC).isoformat(),
125
- (pytz.timezone('Asia/Seoul').localize(datetime.strptime(end_date, '%Y-%m-%d')) + timedelta(days=1)).astimezone(pytz.UTC).isoformat())
126
-
127
- df['observed_at'] = pd.to_datetime(df['observed_at']).dt.tz_convert('Asia/Seoul')
128
- df['tide_level'] = pd.to_numeric(df['tide_level'])
129
-
130
- highest = df.loc[df['tide_level'].idxmax()]
131
- lowest = df.loc[df['tide_level'].idxmin()]
132
- avg_tide = df['tide_level'].mean()
133
- df['date'] = df['observed_at'].dt.date
134
- daily_range = df.groupby('date')['tide_level'].apply(lambda x: x.max() - x.min())
135
- avg_range = daily_range.mean()
136
-
137
- summary = {
138
- "Highest Tide": f"{highest['tide_level']:.1f}cm ({highest['observed_at'].strftime('%Y-%m-%d %H:%M')})",
139
- "Lowest Tide": f"{lowest['tide_level']:.1f}cm ({lowest['observed_at'].strftime('%Y-%m-%d %H:%M')})",
140
- "Average Tide": f"{avg_tide:.1f}cm",
141
- "Average Range": f"{avg_range:.1f}cm"
142
- }
143
 
144
- fig = make_subplots(rows=2, cols=1, subplot_titles=("Daily Tide Variation", "Daily Tide Range"))
145
- fig.add_trace(go.Box(x=df['observed_at'].dt.strftime('%Y-%m-%d'), y=df['tide_level'], name='Tide'), row=1, col=1)
146
- fig.add_trace(go.Bar(x=daily_range.index, y=daily_range.values, name='Range'), row=2, col=1)
147
- fig.update_layout(
148
- height=700,
149
- title_text=f"{STATION_NAMES.get(station_id, station_id)} - {year} {month} Summary"
150
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
- return {"summary": summary, "plot": fig}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
  from datetime import datetime, timedelta
3
+ from typing import Dict, List, Optional, Union
4
  import pytz
 
 
 
5
  from supabase_utils import get_supabase_client
6
  from config import STATION_NAMES
7
 
8
+ # API 응답 표준 포맷
9
+ def create_api_response(success: bool, data: any = None, error: str = None, meta: Dict = None) -> Dict:
10
+ """표준 API 응답 포맷 생성"""
11
+ response = {
12
+ "success": success,
13
+ "timestamp": datetime.now(pytz.timezone('Asia/Seoul')).isoformat(),
14
+ }
15
+
16
+ if meta:
17
+ response["meta"] = meta
18
+
19
+ if success:
20
+ response["data"] = data
21
+ else:
22
+ response["error"] = error or "Unknown error"
23
+
24
+ return response
25
+
26
+ def get_station_meta(station_id: str) -> Dict:
27
+ """관측소 메타 정보 반환"""
28
+ # 관측소 좌표 정보 (실제 좌표)
29
+ STATION_COORDS = {
30
+ "DT_0001": {"lat": 37.452, "lon": 126.592},
31
+ "DT_0002": {"lat": 36.9669, "lon": 126.823},
32
+ "DT_0003": {"lat": 35.4262, "lon": 126.421},
33
+ "DT_0008": {"lat": 37.1922, "lon": 126.647},
34
+ "DT_0017": {"lat": 37.0075, "lon": 126.353},
35
+ "DT_0018": {"lat": 35.9755, "lon": 126.563},
36
+ "DT_0024": {"lat": 36.0069, "lon": 126.688},
37
+ "DT_0025": {"lat": 36.4064, "lon": 126.486},
38
+ "DT_0037": {"lat": 36.1173, "lon": 125.985},
39
+ "DT_0043": {"lat": 37.2394, "lon": 126.429},
40
+ "DT_0050": {"lat": 36.9131, "lon": 126.239},
41
+ "DT_0051": {"lat": 36.1289, "lon": 126.495},
42
+ "DT_0052": {"lat": 37.3382, "lon": 126.586},
43
+ "DT_0065": {"lat": 37.2394, "lon": 126.155},
44
+ "DT_0066": {"lat": 35.6858, "lon": 126.334},
45
+ "DT_0067": {"lat": 36.6737, "lon": 126.132},
46
+ "DT_0068": {"lat": 35.6181, "lon": 126.302},
47
+ }
48
+
49
+ coords = STATION_COORDS.get(station_id, {"lat": 0, "lon": 0})
50
+
51
+ return {
52
+ "obs_post_id": station_id,
53
+ "obs_post_name": STATION_NAMES.get(station_id, "Unknown"),
54
+ "obs_lat": str(coords["lat"]),
55
+ "obs_lon": str(coords["lon"]),
56
+ "data_type": "prediction" # 예측 데이터임을 명시
57
+ }
58
 
59
+ # 1. 현재/미래 조위 조회 (조화 예측 폴백 포함)
60
+ def api_get_tide_level(
61
+ station_id: str,
62
+ target_time: Optional[str] = None,
63
+ use_harmonic_fallback: bool = True
64
+ ) -> Dict:
65
+ """
66
+ 특정 시간의 조위 정보 조회
67
+
68
+ Args:
69
+ station_id: 관측소 ID
70
+ target_time: 조회 시간 (ISO format, None이면 현재 시간)
71
+ use_harmonic_fallback: 최종 예측이 없을 때 조화 예측 사용 여부
72
+
73
+ Returns:
74
+ API 응답 (최종 예측 우선, 없으면 조화 예측)
75
+ """
76
  supabase = get_supabase_client()
77
  if not supabase:
78
+ return create_api_response(False, error="Database connection failed")
79
+
 
80
  try:
81
+ # 대상 시간 파싱
82
+ if target_time:
83
+ query_time = datetime.fromisoformat(target_time.replace('Z', '+00:00'))
84
+ else:
85
+ query_time = datetime.now(pytz.timezone('Asia/Seoul'))
86
+
87
+ query_str = query_time.strftime('%Y-%m-%dT%H:%M:%S')
88
+
89
+ # 1차: 최종 예측 (tide_predictions) 조회
90
+ result = supabase.table('tide_predictions')\
91
+ .select('*')\
92
+ .eq('station_id', station_id)\
93
+ .gte('predicted_at', query_str)\
94
+ .order('predicted_at')\
95
+ .limit(1)\
96
+ .execute()
97
+
98
+ if result.data:
99
+ # 최종 예측 데이터가 있는 경우
100
+ data = result.data[0]
101
+ return create_api_response(
102
+ success=True,
103
+ data={
104
+ "record_time": data['predicted_at'],
105
+ "final_value": round(data.get('final_tide_level', 0), 1),
106
+ "residual_value": round(data.get('predicted_residual', 0), 1),
107
+ "harmonic_value": round(data.get('harmonic_level', 0), 1),
108
+ "data_source": "final_prediction",
109
+ "confidence": "high"
110
+ },
111
+ meta=get_station_meta(station_id)
112
+ )
113
+
114
+ # 2차: 조화 예측 (harmonic_predictions) 폴백
115
+ if use_harmonic_fallback:
116
+ result = supabase.table('harmonic_predictions')\
117
+ .select('*')\
118
+ .eq('station_id', station_id)\
119
+ .gte('predicted_at', query_str)\
120
+ .order('predicted_at')\
121
+ .limit(1)\
122
+ .execute()
123
+
124
+ if result.data:
125
+ data = result.data[0]
126
+ return create_api_response(
127
+ success=True,
128
+ data={
129
+ "record_time": data['predicted_at'],
130
+ "final_value": round(data.get('harmonic_level', 0), 1),
131
+ "residual_value": None, # 잔차 예측 없음
132
+ "harmonic_value": round(data.get('harmonic_level', 0), 1),
133
+ "data_source": "harmonic_only",
134
+ "confidence": "medium",
135
+ "note": "잔차 예측이 없어 조화 예측만 제공됩니다"
136
+ },
137
+ meta=get_station_meta(station_id)
138
+ )
139
+
140
+ return create_api_response(
141
+ success=False,
142
+ error=f"No data available for {query_str}",
143
+ meta=get_station_meta(station_id)
144
+ )
145
+
146
+ except Exception as e:
147
+ return create_api_response(False, error=str(e))
148
 
149
+ # 2. 시간대별 조위 조회 (공공 API 형식)
150
+ def api_get_tide_series(
151
+ station_id: str,
152
+ start_time: Optional[str] = None,
153
+ end_time: Optional[str] = None,
154
+ interval_minutes: int = 60
155
+ ) -> Dict:
156
+ """
157
+ 시간대별 조위 정보 조회 (공공 API 형식과 유사)
158
+
159
+ Args:
160
+ station_id: 관측소 ID
161
+ start_time: 시작 시간 (None이면 현재)
162
+ end_time: 종료 시간 (None이면 24시간 후)
163
+ interval_minutes: 데이터 간격 (기본 60분)
164
+
165
+ Returns:
166
+ 시계열 데이터
167
+ """
168
+ supabase = get_supabase_client()
169
+ if not supabase:
170
+ return create_api_response(False, error="Database connection failed")
171
+
172
+ try:
173
+ # 시간 범위 설정
174
+ kst = pytz.timezone('Asia/Seoul')
175
+ if start_time:
176
+ start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
177
+ else:
178
+ start_dt = datetime.now(kst)
179
+
180
+ if end_time:
181
+ end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
182
+ else:
183
+ end_dt = start_dt + timedelta(hours=24)
184
+
185
+ # 최종 예측 조회
186
+ result = supabase.table('tide_predictions')\
187
+ .select('predicted_at, final_tide_level, predicted_residual, harmonic_level')\
188
+ .eq('station_id', station_id)\
189
+ .gte('predicted_at', start_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
190
+ .lte('predicted_at', end_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
191
+ .order('predicted_at')\
192
  .execute()
193
+
194
+ data_points = []
195
+ data_source = "final_prediction"
196
+
197
+ if result.data:
198
+ # 간격에 맞춰 데이터 필터링
199
+ for i, item in enumerate(result.data):
200
+ if i % (interval_minutes // 5) == 0: # 5분 간격 데이터 기준
201
+ data_points.append({
202
+ "record_time": item['predicted_at'],
203
+ "real_value": str(round(item['final_tide_level'], 0)), # 정수로 표시
204
+ "pre_value": str(round(item['harmonic_level'], 0)),
205
+ "residual": str(round(item['predicted_residual'], 0))
206
+ })
207
+ else:
208
+ # 조화 예측 폴백
209
+ result = supabase.table('harmonic_predictions')\
210
+ .select('predicted_at, harmonic_level')\
211
+ .eq('station_id', station_id)\
212
+ .gte('predicted_at', start_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
213
+ .lte('predicted_at', end_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
214
+ .order('predicted_at')\
215
+ .execute()
216
+
217
+ if result.data:
218
+ data_source = "harmonic_only"
219
+ for i, item in enumerate(result.data):
220
+ if i % (interval_minutes // 5) == 0:
221
+ data_points.append({
222
+ "record_time": item['predicted_at'],
223
+ "real_value": str(round(item['harmonic_level'], 0)),
224
+ "pre_value": str(round(item['harmonic_level'], 0)),
225
+ "residual": "0"
226
+ })
227
+
228
+ meta = get_station_meta(station_id)
229
+ meta["data_source"] = data_source
230
+ meta["data_count"] = len(data_points)
231
+ meta["interval_minutes"] = interval_minutes
232
+
233
+ return {
234
+ "result": {
235
+ "meta": meta,
236
+ "data": data_points
237
+ }
238
+ }
239
+
240
+ except Exception as e:
241
+ return create_api_response(False, error=str(e))
242
 
243
+ # 3. 만조/간조 정보
244
+ def api_get_extremes_info(
245
+ station_id: str,
246
+ date: Optional[str] = None,
247
+ include_secondary: bool = False
248
+ ) -> Dict:
249
+ """
250
+ 특정 날짜의 만조/간조 정보
251
+
252
+ Args:
253
+ station_id: 관측소 ID
254
+ date: 날짜 (YYYY-MM-DD, None이면 오늘)
255
+ include_secondary: 부차 만조/간조 포함 여부
256
+
257
+ Returns:
258
+ 만조/간조 시간과 수위
259
+ """
260
+ supabase = get_supabase_client()
261
+ if not supabase:
262
+ return create_api_response(False, error="Database connection failed")
263
+
264
+ try:
265
+ # 날짜 범위 설정
266
+ if date:
267
+ target_date = datetime.strptime(date, '%Y-%m-%d')
268
+ else:
269
+ target_date = datetime.now(pytz.timezone('Asia/Seoul'))
270
+
271
+ start_dt = target_date.replace(hour=0, minute=0, second=0)
272
+ end_dt = target_date.replace(hour=23, minute=59, second=59)
273
+
274
+ # 데이터 조회 (최종 예측 우선)
275
+ result = supabase.table('tide_predictions')\
276
+ .select('predicted_at, final_tide_level')\
277
+ .eq('station_id', station_id)\
278
+ .gte('predicted_at', start_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
279
+ .lte('predicted_at', end_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
280
+ .order('predicted_at')\
281
+ .execute()
282
+
283
+ data_source = "final_prediction"
284
+
285
+ # 데이터가 없으면 조화 예측 사용
286
  if not result.data:
287
+ result = supabase.table('harmonic_predictions')\
288
+ .select('predicted_at, harmonic_level')\
289
+ .eq('station_id', station_id)\
290
+ .gte('predicted_at', start_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
291
+ .lte('predicted_at', end_dt.strftime('%Y-%m-%dT%H:%M:%S'))\
292
+ .order('predicted_at')\
293
+ .execute()
294
+
295
+ if result.data:
296
+ # 컬럼명 통일
297
+ for item in result.data:
298
+ item['final_tide_level'] = item.pop('harmonic_level')
299
+ data_source = "harmonic_only"
300
+
301
+ if not result.data or len(result.data) < 3:
302
+ return create_api_response(False, error="Insufficient data for extremes")
303
+
304
+ # 극값 찾기
305
+ extremes = []
306
+ data = result.data
307
+
308
+ for i in range(1, len(data) - 1):
309
+ prev_level = data[i-1]['final_tide_level']
310
+ curr_level = data[i]['final_tide_level']
311
+ next_level = data[i+1]['final_tide_level']
312
+
313
+ # 만조 (극대값)
314
+ if curr_level > prev_level and curr_level > next_level:
315
+ extremes.append({
316
+ 'type': 'high_tide',
317
+ 'time': data[i]['predicted_at'],
318
+ 'level': round(curr_level, 1),
319
+ 'time_kr': datetime.fromisoformat(data[i]['predicted_at'].replace('Z', '+00:00'))
320
+ .strftime('%H시 %M분')
321
+ })
322
+ # 간조 (극소값)
323
+ elif curr_level < prev_level and curr_level < next_level:
324
+ extremes.append({
325
+ 'type': 'low_tide',
326
+ 'time': data[i]['predicted_at'],
327
+ 'level': round(curr_level, 1),
328
+ 'time_kr': datetime.fromisoformat(data[i]['predicted_at'].replace('Z', '+00:00'))
329
+ .strftime('%H시 %M분')
330
+ })
331
+
332
+ # 주요 만조/간조만 필터링 (부차 제외)
333
+ if not include_secondary and len(extremes) > 4:
334
+ # 수위 차이가 큰 것들만 선택
335
+ high_tides = sorted([e for e in extremes if e['type'] == 'high_tide'],
336
+ key=lambda x: x['level'], reverse=True)[:2]
337
+ low_tides = sorted([e for e in extremes if e['type'] == 'low_tide'],
338
+ key=lambda x: x['level'])[:2]
339
+ extremes = sorted(high_tides + low_tides, key=lambda x: x['time'])
340
+
341
+ meta = get_station_meta(station_id)
342
+ meta["date"] = target_date.strftime('%Y-%m-%d')
343
+ meta["data_source"] = data_source
344
+
345
+ return create_api_response(
346
+ success=True,
347
+ data={
348
+ "extremes": extremes,
349
+ "summary": {
350
+ "high_tide_count": len([e for e in extremes if e['type'] == 'high_tide']),
351
+ "low_tide_count": len([e for e in extremes if e['type'] == 'low_tide']),
352
+ "max_level": max([e['level'] for e in extremes]) if extremes else None,
353
+ "min_level": min([e['level'] for e in extremes]) if extremes else None
354
+ }
355
+ },
356
+ meta=meta
357
+ )
358
+
359
  except Exception as e:
360
+ return create_api_response(False, error=str(e))
 
361
 
362
+ # 4. 위험 수위 알림
363
+ def api_check_tide_alert(
364
+ station_id: str,
365
+ hours_ahead: int = 24,
366
+ warning_level: float = 700.0,
367
+ danger_level: float = 750.0
368
+ ) -> Dict:
369
  """
370
+ 위험 수위 체크 알림
371
+
372
+ Args:
373
+ station_id: 관측소 ID
374
+ hours_ahead: 확인할 시간 (기본 24시간)
375
+ warning_level: 주의 수위 (cm)
376
+ danger_level: 경고 수위 (cm)
377
+
378
+ Returns:
379
+ 위험 수위 정보
380
  """
381
+ supabase = get_supabase_client()
382
+ if not supabase:
383
+ return create_api_response(False, error="Database connection failed")
384
+
385
+ try:
386
+ now = datetime.now(pytz.timezone('Asia/Seoul'))
387
+ end_time = now + timedelta(hours=hours_ahead)
388
+
389
+ # 위험 수위 데이터 조회
390
+ result = supabase.table('tide_predictions')\
391
+ .select('predicted_at, final_tide_level')\
392
+ .eq('station_id', station_id)\
393
+ .gte('predicted_at', now.strftime('%Y-%m-%dT%H:%M:%S'))\
394
+ .lte('predicted_at', end_time.strftime('%Y-%m-%dT%H:%M:%S'))\
395
+ .gte('final_tide_level', warning_level)\
396
+ .order('predicted_at')\
397
+ .execute()
398
+
399
+ alerts = []
400
+ alert_level = "safe"
401
+
402
+ if result.data:
403
+ for item in result.data:
404
+ level = item['final_tide_level']
405
+
406
+ if level >= danger_level:
407
+ severity = "danger"
408
+ alert_level = "danger"
409
+ elif level >= warning_level:
410
+ severity = "warning"
411
+ if alert_level != "danger":
412
+ alert_level = "warning"
413
+ else:
414
+ continue
415
+
416
+ alerts.append({
417
+ "time": item['predicted_at'],
418
+ "level": round(level, 1),
419
+ "severity": severity,
420
+ "time_kr": datetime.fromisoformat(item['predicted_at'].replace('Z', '+00:00'))
421
+ .strftime('%m월 %d일 %H시 %M분')
422
+ })
423
+
424
+ # 첫 위험 시간 계산
425
+ first_alert_time = None
426
+ if alerts:
427
+ first_alert_time = alerts[0]['time']
428
+ time_until = (datetime.fromisoformat(first_alert_time.replace('Z', '+00:00')) - now).total_seconds() / 3600
429
+ else:
430
+ time_until = None
431
+
432
+ meta = get_station_meta(station_id)
433
+ meta["check_time"] = now.isoformat()
434
+ meta["hours_ahead"] = hours_ahead
435
+
436
+ return create_api_response(
437
+ success=True,
438
+ data={
439
+ "alert_level": alert_level,
440
+ "alert_count": len(alerts),
441
+ "first_alert_time": first_alert_time,
442
+ "hours_until_first": round(time_until, 1) if time_until else None,
443
+ "alerts": alerts[:10], # 최대 10개만
444
+ "thresholds": {
445
+ "warning": warning_level,
446
+ "danger": danger_level
447
+ }
448
+ },
449
+ meta=meta
450
  )
451
+
452
+ except Exception as e:
453
+ return create_api_response(False, error=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
+ # 5. 다중 관측소 비교
456
+ def api_compare_stations(
457
+ station_ids: List[str],
458
+ target_time: Optional[str] = None
459
+ ) -> Dict:
460
+ """
461
+ 여러 관측소 동시 비교
462
+
463
+ Args:
464
+ station_ids: 관측소 ID 리스트
465
+ target_time: 비교 시간 (None이면 현재)
466
+
467
+ Returns:
468
+ 관측소별 조위 비교 정보
469
+ """
470
+ if not station_ids:
471
+ return create_api_response(False, error="No station IDs provided")
472
+
473
+ try:
474
+ comparison_data = []
475
+
476
+ for station_id in station_ids[:10]: # 최대 10개 관측소
477
+ result = api_get_tide_level(station_id, target_time)
478
+
479
+ if result.get("success") and result.get("data"):
480
+ data = result["data"]
481
+ comparison_data.append({
482
+ "station_id": station_id,
483
+ "station_name": STATION_NAMES.get(station_id, "Unknown"),
484
+ "tide_level": data.get("final_value"),
485
+ "data_source": data.get("data_source"),
486
+ "time": data.get("record_time")
487
+ })
488
+ else:
489
+ comparison_data.append({
490
+ "station_id": station_id,
491
+ "station_name": STATION_NAMES.get(station_id, "Unknown"),
492
+ "tide_level": None,
493
+ "data_source": "no_data",
494
+ "time": None
495
+ })
496
+
497
+ # 수위 기준 정렬
498
+ comparison_data.sort(key=lambda x: x['tide_level'] if x['tide_level'] else 0, reverse=True)
499
+
500
+ # 통계 계산
501
+ valid_levels = [d['tide_level'] for d in comparison_data if d['tide_level']]
502
+
503
+ stats = {
504
+ "max_level": max(valid_levels) if valid_levels else None,
505
+ "min_level": min(valid_levels) if valid_levels else None,
506
+ "avg_level": round(sum(valid_levels) / len(valid_levels), 1) if valid_levels else None,
507
+ "station_count": len(comparison_data),
508
+ "valid_count": len(valid_levels)
509
+ }
510
+
511
+ return create_api_response(
512
+ success=True,
513
+ data={
514
+ "comparison": comparison_data,
515
+ "statistics": stats
516
+ },
517
+ meta={
518
+ "query_time": target_time or datetime.now(pytz.timezone('Asia/Seoul')).isoformat(),
519
+ "station_count": len(station_ids)
520
+ }
521
+ )
522
+
523
+ except Exception as e:
524
+ return create_api_response(False, error=str(e))
525
 
526
+ # 6. 건강 체크 / 상태 확인
527
+ def api_health_check() -> Dict:
528
+ """
529
+ API 및 데이터베이스 상태 확인
530
+
531
+ Returns:
532
+ 시스템 상태 정보
533
+ """
534
+ try:
535
+ supabase = get_supabase_client()
536
+ db_status = "connected" if supabase else "disconnected"
537
+
538
+ # 데이터 가용성 체크
539
+ data_availability = {}
540
+
541
+ if supabase:
542
+ # 최종 예측 데이터 확인
543
+ result = supabase.table('tide_predictions')\
544
+ .select('station_id', count='exact')\
545
+ .limit(1)\
546
+ .execute()
547
+
548
+ tide_count = result.count if hasattr(result, 'count') else 0
549
+
550
+ # 조화 예측 데이터 확인
551
+ result = supabase.table('harmonic_predictions')\
552
+ .select('station_id', count='exact')\
553
+ .limit(1)\
554
+ .execute()
555
+
556
+ harmonic_count = result.count if hasattr(result, 'count') else 0
557
+
558
+ data_availability = {
559
+ "tide_predictions": tide_count,
560
+ "harmonic_predictions": harmonic_count
561
+ }
562
+
563
+ return create_api_response(
564
+ success=True,
565
+ data={
566
+ "status": "healthy" if db_status == "connected" else "degraded",
567
+ "database": db_status,
568
+ "data_availability": data_availability,
569
+ "api_version": "1.0.0",
570
+ "endpoints": [
571
+ "/api/tide_level",
572
+ "/api/tide_series",
573
+ "/api/extremes",
574
+ "/api/alert",
575
+ "/api/compare",
576
+ "/api/health"
577
+ ]
578
+ }
579
+ )
580
+
581
+ except Exception as e:
582
+ return create_api_response(
583
+ success=False,
584
+ error=str(e),
585
+ data={"status": "error"}
586
+ )