Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -6,7 +6,7 @@ import threading
|
|
| 6 |
import time
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
from collections import defaultdict
|
| 9 |
-
|
| 10 |
import gradio as gr
|
| 11 |
import pandas as pd
|
| 12 |
import numpy as np
|
|
@@ -41,7 +41,7 @@ import tropycal.tracks as tracks
|
|
| 41 |
# Configuration and Setup
|
| 42 |
# -----------------------------
|
| 43 |
logging.basicConfig(
|
| 44 |
-
level=logging.INFO,
|
| 45 |
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 46 |
)
|
| 47 |
|
|
@@ -53,9 +53,9 @@ DATA_PATH = args.data_path
|
|
| 53 |
# Data paths
|
| 54 |
ONI_DATA_PATH = os.path.join(DATA_PATH, 'oni_data.csv')
|
| 55 |
TYPHOON_DATA_PATH = os.path.join(DATA_PATH, 'processed_typhoon_data.csv')
|
| 56 |
-
MERGED_DATA_CSV = os.path.join(DATA_PATH, 'merged_typhoon_era5_data.csv')
|
| 57 |
|
| 58 |
-
# IBTrACS settings (
|
| 59 |
BASIN_FILES = {
|
| 60 |
'EP': 'ibtracs.EP.list.v04r01.csv',
|
| 61 |
'NA': 'ibtracs.NA.list.v04r01.csv',
|
|
@@ -137,7 +137,6 @@ def convert_oni_ascii_to_csv(input_file, output_file):
|
|
| 137 |
year = str(int(year)-1)
|
| 138 |
data[year][month-1] = anom
|
| 139 |
with open(output_file, 'w', newline='') as f:
|
| 140 |
-
writer = pd.ExcelWriter(f)
|
| 141 |
writer = csv.writer(f)
|
| 142 |
writer.writerow(['Year','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'])
|
| 143 |
for year in sorted(data.keys()):
|
|
@@ -222,7 +221,9 @@ def classify_enso_phases(oni_value):
|
|
| 222 |
else:
|
| 223 |
return 'Neutral'
|
| 224 |
|
| 225 |
-
# -------------
|
|
|
|
|
|
|
| 226 |
def perform_wind_regression(start_year, start_month, end_year, end_month):
|
| 227 |
start_date = datetime(start_year, start_month, 1)
|
| 228 |
end_date = datetime(end_year, end_month, 28)
|
|
@@ -262,7 +263,9 @@ def perform_longitude_regression(start_year, start_month, end_year, end_month):
|
|
| 262 |
p_value = model.pvalues['ONI']
|
| 263 |
return f"Longitude Regression: β1={beta_1:.4f}, Odds Ratio={exp_beta_1:.4f}, P-value={p_value:.4f}"
|
| 264 |
|
| 265 |
-
# -------------
|
|
|
|
|
|
|
| 266 |
def load_ibtracs_data():
|
| 267 |
ibtracs_data = {}
|
| 268 |
for basin, filename in BASIN_FILES.items():
|
|
@@ -286,14 +289,18 @@ def load_ibtracs_data():
|
|
| 286 |
|
| 287 |
ibtracs = load_ibtracs_data()
|
| 288 |
|
| 289 |
-
# -------------
|
|
|
|
|
|
|
| 290 |
update_oni_data()
|
| 291 |
oni_data, typhoon_data = load_data(ONI_DATA_PATH, TYPHOON_DATA_PATH)
|
| 292 |
oni_long = process_oni_data(oni_data)
|
| 293 |
typhoon_max = process_typhoon_data(typhoon_data)
|
| 294 |
merged_data = merge_data(oni_long, typhoon_max)
|
| 295 |
|
| 296 |
-
# -------------
|
|
|
|
|
|
|
| 297 |
def generate_typhoon_tracks(filtered_data, typhoon_search):
|
| 298 |
fig = go.Figure()
|
| 299 |
for sid in filtered_data['SID'].unique():
|
|
@@ -394,11 +401,12 @@ def get_full_tracks(start_year, start_month, end_year, end_month, enso_phase, ty
|
|
| 394 |
for sid in unique_storms:
|
| 395 |
storm_data = typhoon_data[typhoon_data['SID']==sid]
|
| 396 |
name = storm_data['NAME'].iloc[0] if pd.notnull(storm_data['NAME'].iloc[0]) else "Unnamed"
|
|
|
|
| 397 |
storm_oni = filtered_data[filtered_data['SID']==sid]['ONI'].iloc[0]
|
| 398 |
color = 'red' if storm_oni>=0.5 else ('blue' if storm_oni<=-0.5 else 'green')
|
| 399 |
fig.add_trace(go.Scattergeo(
|
| 400 |
lon=storm_data['LON'], lat=storm_data['LAT'], mode='lines',
|
| 401 |
-
name=f"{name} ({
|
| 402 |
line=dict(width=1.5, color=color), hoverinfo="name"
|
| 403 |
))
|
| 404 |
if typhoon_search:
|
|
@@ -408,7 +416,7 @@ def get_full_tracks(start_year, start_month, end_year, end_month, enso_phase, ty
|
|
| 408 |
storm_data = typhoon_data[typhoon_data['SID']==sid]
|
| 409 |
fig.add_trace(go.Scattergeo(
|
| 410 |
lon=storm_data['LON'], lat=storm_data['LAT'], mode='lines+markers',
|
| 411 |
-
name=f"MATCHED: {storm_data['NAME'].iloc[0]}
|
| 412 |
line=dict(width=3, color='yellow'),
|
| 413 |
marker=dict(size=5), hoverinfo="name"
|
| 414 |
))
|
|
@@ -430,7 +438,7 @@ def get_full_tracks(start_year, start_month, end_year, end_month, enso_phase, ty
|
|
| 430 |
)
|
| 431 |
fig.add_annotation(
|
| 432 |
x=0.02, y=0.98, xref="paper", yref="paper",
|
| 433 |
-
text="Red: El Niño, Blue: La
|
| 434 |
showarrow=False, align="left",
|
| 435 |
bgcolor="rgba(255,255,255,0.8)"
|
| 436 |
)
|
|
@@ -476,10 +484,12 @@ def categorize_typhoon_by_standard(wind_speed, standard='atlantic'):
|
|
| 476 |
return 'Tropical Storm', atlantic_standard['Tropical Storm']['hex']
|
| 477 |
return 'Tropical Depression', atlantic_standard['Tropical Depression']['hex']
|
| 478 |
|
| 479 |
-
# -------------
|
|
|
|
|
|
|
| 480 |
def update_route_clusters(start_year, start_month, end_year, end_month, enso_value, season):
|
| 481 |
try:
|
| 482 |
-
# Merge raw typhoon data with ONI so
|
| 483 |
raw_data = typhoon_data.copy()
|
| 484 |
raw_data['Year'] = raw_data['ISO_TIME'].dt.year
|
| 485 |
raw_data['Month'] = raw_data['ISO_TIME'].dt.strftime('%m')
|
|
@@ -497,7 +507,7 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 497 |
merged_raw = merged_raw[merged_raw['ENSO_Phase'] == enso_value.capitalize()]
|
| 498 |
logging.info(f"Total points after ENSO filtering: {merged_raw.shape[0]}")
|
| 499 |
|
| 500 |
-
#
|
| 501 |
wp_data = merged_raw[(merged_raw['LON'] >= 100) & (merged_raw['LON'] <= 180) &
|
| 502 |
(merged_raw['LAT'] >= 0) & (merged_raw['LAT'] <= 40)]
|
| 503 |
logging.info(f"Total points after WP regional filtering: {wp_data.shape[0]}")
|
|
@@ -505,7 +515,7 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 505 |
logging.info("WP regional filter returned no data; using all filtered data.")
|
| 506 |
wp_data = merged_raw
|
| 507 |
|
| 508 |
-
# Group by storm ID
|
| 509 |
all_storms_data = []
|
| 510 |
for sid, group in wp_data.groupby('SID'):
|
| 511 |
group = group.sort_values('ISO_TIME')
|
|
@@ -514,7 +524,7 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 514 |
lons = group['LON'].astype(float).values
|
| 515 |
if len(lons) < 2:
|
| 516 |
continue
|
| 517 |
-
# Also
|
| 518 |
wind = group['USA_WIND'].astype(float).values if 'USA_WIND' in group.columns else None
|
| 519 |
pres = group['USA_PRES'].astype(float).values if 'USA_PRES' in group.columns else None
|
| 520 |
all_storms_data.append((sid, lons, lats, times, wind, pres))
|
|
@@ -522,7 +532,7 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 522 |
if not all_storms_data:
|
| 523 |
return go.Figure(), go.Figure(), make_subplots(rows=2, cols=1), "No valid storms for clustering."
|
| 524 |
|
| 525 |
-
# Interpolate each storm's route
|
| 526 |
max_length = max(len(item[1]) for item in all_storms_data)
|
| 527 |
route_vectors = []
|
| 528 |
wind_curves = []
|
|
@@ -542,7 +552,7 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 542 |
continue
|
| 543 |
route_vectors.append(route_vector)
|
| 544 |
storm_ids.append(sid)
|
| 545 |
-
# Interpolate wind and pressure if available
|
| 546 |
if wind is not None and len(wind) >= 2:
|
| 547 |
try:
|
| 548 |
wind_interp = interp1d(t, wind, kind='linear', fill_value='extrapolate')(t_new)
|
|
@@ -573,15 +583,14 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 573 |
tsne = TSNE(n_components=2, random_state=42, verbose=1)
|
| 574 |
tsne_results = tsne.fit_transform(route_vectors)
|
| 575 |
|
| 576 |
-
# Dynamic DBSCAN: choose eps
|
| 577 |
selected_labels = None
|
| 578 |
selected_eps = None
|
| 579 |
for eps in np.linspace(1.0, 10.0, 91):
|
| 580 |
dbscan = DBSCAN(eps=eps, min_samples=3)
|
| 581 |
labels = dbscan.fit_predict(tsne_results)
|
| 582 |
clusters = set(labels) - {-1}
|
| 583 |
-
|
| 584 |
-
if 5 <= num_clusters <= 20:
|
| 585 |
selected_labels = labels
|
| 586 |
selected_eps = eps
|
| 587 |
break
|
|
@@ -589,7 +598,7 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 589 |
selected_eps = 5.0
|
| 590 |
dbscan = DBSCAN(eps=selected_eps, min_samples=3)
|
| 591 |
selected_labels = dbscan.fit_predict(tsne_results)
|
| 592 |
-
logging.info(f"Selected DBSCAN eps: {selected_eps:.2f} yielding {len(set(selected_labels)
|
| 593 |
|
| 594 |
# TSNE scatter plot
|
| 595 |
fig_tsne = go.Figure()
|
|
@@ -619,9 +628,9 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 619 |
yaxis_title="t-SNE Dim 2"
|
| 620 |
)
|
| 621 |
|
| 622 |
-
# For each cluster, compute mean route, mean wind
|
| 623 |
fig_routes = go.Figure()
|
| 624 |
-
cluster_stats = [] # To hold mean curves
|
| 625 |
for i, label in enumerate(unique_labels):
|
| 626 |
indices = np.where(selected_labels == label)[0]
|
| 627 |
cluster_ids = [storm_ids[j] for j in indices]
|
|
@@ -637,23 +646,14 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 637 |
line=dict(width=4, color=colors[i % len(colors)]),
|
| 638 |
name=f"Cluster {label} Mean Route"
|
| 639 |
))
|
| 640 |
-
#
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
# Compute mean curves (if available)
|
| 646 |
-
if cluster_winds.size > 0:
|
| 647 |
-
mean_wind_curve = np.nanmean(cluster_winds, axis=0)
|
| 648 |
-
else:
|
| 649 |
-
mean_wind_curve = np.full(max_length, np.nan)
|
| 650 |
-
if cluster_pres.size > 0:
|
| 651 |
-
mean_pres_curve = np.nanmean(cluster_pres, axis=0)
|
| 652 |
-
else:
|
| 653 |
-
mean_pres_curve = np.full(max_length, np.nan)
|
| 654 |
cluster_stats.append((label, mean_wind_curve, mean_pres_curve))
|
| 655 |
|
| 656 |
-
# Create cluster stats plot with curves vs normalized route index (0 to 1)
|
| 657 |
x_axis = np.linspace(0, 1, max_length)
|
| 658 |
fig_stats = make_subplots(rows=2, cols=1, shared_xaxes=True,
|
| 659 |
subplot_titles=("Mean Wind Speed (knots)", "Mean MSLP (hPa)"))
|
|
@@ -687,7 +687,9 @@ def update_route_clusters(start_year, start_month, end_year, end_month, enso_val
|
|
| 687 |
logging.error(f"Error in TSNE clustering: {e}")
|
| 688 |
return go.Figure(), go.Figure(), make_subplots(rows=2, cols=1), f"Error in TSNE clustering: {e}"
|
| 689 |
|
| 690 |
-
# -------------
|
|
|
|
|
|
|
| 691 |
def generate_track_video_from_csv(year, storm_id, standard):
|
| 692 |
storm_df = typhoon_data[typhoon_data['SID'] == storm_id].copy()
|
| 693 |
if storm_df.empty:
|
|
@@ -702,6 +704,7 @@ def generate_track_video_from_csv(year, storm_id, standard):
|
|
| 702 |
else:
|
| 703 |
winds = np.full(len(lats), np.nan)
|
| 704 |
storm_name = storm_df['NAME'].iloc[0]
|
|
|
|
| 705 |
season = storm_df['SEASON'].iloc[0]
|
| 706 |
|
| 707 |
min_lat, max_lat = np.min(lats), np.max(lats)
|
|
@@ -718,12 +721,13 @@ def generate_track_video_from_csv(year, storm_id, standard):
|
|
| 718 |
ax.coastlines(resolution='50m', color='black', linewidth=1)
|
| 719 |
gl = ax.gridlines(draw_labels=True, color='gray', alpha=0.4, linestyle='--')
|
| 720 |
gl.top_labels = gl.right_labels = False
|
| 721 |
-
ax.set_title(f"{year} {storm_name} - {season}", fontsize=14)
|
| 722 |
|
| 723 |
line, = ax.plot([], [], transform=ccrs.PlateCarree(), color='blue', linewidth=2)
|
| 724 |
point, = ax.plot([], [], 'o', markersize=8, transform=ccrs.PlateCarree())
|
| 725 |
date_text = ax.text(0.02, 0.02, '', transform=ax.transAxes, fontsize=10,
|
| 726 |
bbox=dict(facecolor='white', alpha=0.8))
|
|
|
|
| 727 |
storm_info_text = fig.text(0.70, 0.60, '', fontsize=10,
|
| 728 |
bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.5'))
|
| 729 |
|
|
@@ -750,10 +754,7 @@ def generate_track_video_from_csv(year, storm_id, standard):
|
|
| 750 |
point.set_color(color)
|
| 751 |
dt_str = pd.to_datetime(times[frame]).strftime('%Y-%m-%d %H:%M')
|
| 752 |
date_text.set_text(dt_str)
|
| 753 |
-
info_str = (f"Name: {storm_name}\
|
| 754 |
-
f"Date: {dt_str}\n"
|
| 755 |
-
f"Wind: {wind_speed:.1f} kt\n"
|
| 756 |
-
f"Category: {category}")
|
| 757 |
storm_info_text.set_text(info_str)
|
| 758 |
return line, point, date_text, storm_info_text
|
| 759 |
|
|
@@ -771,7 +772,9 @@ def simplified_track_video(year, basin, typhoon, standard):
|
|
| 771 |
storm_id = typhoon.split('(')[-1].strip(')')
|
| 772 |
return generate_track_video_from_csv(year, storm_id, standard)
|
| 773 |
|
| 774 |
-
# -------------
|
|
|
|
|
|
|
| 775 |
basin_to_prefix = {
|
| 776 |
"All Basins": "all",
|
| 777 |
"NA - North Atlantic": "NA",
|
|
@@ -835,7 +838,9 @@ def update_typhoon_options_anim(year, basin):
|
|
| 835 |
logging.error(f"Error in update_typhoon_options_anim: {e}")
|
| 836 |
return gr.update(choices=[], value=None)
|
| 837 |
|
| 838 |
-
# -------------
|
|
|
|
|
|
|
| 839 |
with gr.Blocks(title="Typhoon Analysis Dashboard") as demo:
|
| 840 |
gr.Markdown("# Typhoon Analysis Dashboard")
|
| 841 |
|
|
@@ -850,10 +855,9 @@ with gr.Blocks(title="Typhoon Analysis Dashboard") as demo:
|
|
| 850 |
- **Wind Analysis**: Examine wind speed vs ONI relationships.
|
| 851 |
- **Pressure Analysis**: Analyze pressure vs ONI relationships.
|
| 852 |
- **Longitude Analysis**: Study typhoon generation longitude vs ONI.
|
| 853 |
-
- **Path Animation**: View animated storm tracks on a free stock world map (centered at 180°) with a dynamic sidebar
|
| 854 |
-
- **TSNE Cluster**: Perform t-SNE clustering on WP storm routes using raw merged typhoon+ONI data.
|
| 855 |
-
|
| 856 |
-
are computed from start to end.
|
| 857 |
""")
|
| 858 |
|
| 859 |
with gr.Tab("Track Visualization"):
|
|
@@ -918,9 +922,10 @@ with gr.Blocks(title="Typhoon Analysis Dashboard") as demo:
|
|
| 918 |
outputs=[regression_plot, slopes_text, lon_regression_results])
|
| 919 |
|
| 920 |
with gr.Tab("Tropical Cyclone Path Animation"):
|
|
|
|
| 921 |
with gr.Row():
|
| 922 |
year_dropdown = gr.Dropdown(label="Year", choices=[str(y) for y in range(1950,2025)], value="2000")
|
| 923 |
-
|
| 924 |
with gr.Row():
|
| 925 |
typhoon_dropdown = gr.Dropdown(label="Tropical Cyclone")
|
| 926 |
standard_dropdown = gr.Dropdown(label="Classification Standard", choices=['atlantic','taiwan'], value='atlantic')
|
|
@@ -928,16 +933,16 @@ with gr.Blocks(title="Typhoon Analysis Dashboard") as demo:
|
|
| 928 |
path_video = gr.Video(label="Tropical Cyclone Path Animation", format="mp4", interactive=False, elem_id="path_video")
|
| 929 |
animation_info = gr.Markdown("""
|
| 930 |
### Animation Instructions
|
| 931 |
-
1. Select a year
|
| 932 |
2. Choose a tropical cyclone from the populated list.
|
| 933 |
3. Select a classification standard (Atlantic or Taiwan).
|
| 934 |
4. Click "Generate Animation".
|
| 935 |
-
5. The animation displays the storm track on a free stock world map (centered at 180°) with a dynamic sidebar
|
|
|
|
| 936 |
""")
|
| 937 |
-
year_dropdown.change(fn=update_typhoon_options_anim, inputs=[year_dropdown,
|
| 938 |
-
basin_dropdown.change(fn=update_typhoon_options_anim, inputs=[year_dropdown, basin_dropdown], outputs=typhoon_dropdown)
|
| 939 |
animate_btn.click(fn=simplified_track_video,
|
| 940 |
-
inputs=[year_dropdown,
|
| 941 |
outputs=path_video)
|
| 942 |
|
| 943 |
with gr.Tab("TSNE Cluster"):
|
|
|
|
| 6 |
import time
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
from collections import defaultdict
|
| 9 |
+
|
| 10 |
import gradio as gr
|
| 11 |
import pandas as pd
|
| 12 |
import numpy as np
|
|
|
|
| 41 |
# Configuration and Setup
|
| 42 |
# -----------------------------
|
| 43 |
logging.basicConfig(
|
| 44 |
+
level=logging.INFO,
|
| 45 |
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 46 |
)
|
| 47 |
|
|
|
|
| 53 |
# Data paths
|
| 54 |
ONI_DATA_PATH = os.path.join(DATA_PATH, 'oni_data.csv')
|
| 55 |
TYPHOON_DATA_PATH = os.path.join(DATA_PATH, 'processed_typhoon_data.csv')
|
| 56 |
+
MERGED_DATA_CSV = os.path.join(DATA_PATH, 'merged_typhoon_era5_data.csv')
|
| 57 |
|
| 58 |
+
# IBTrACS settings (for typhoon options)
|
| 59 |
BASIN_FILES = {
|
| 60 |
'EP': 'ibtracs.EP.list.v04r01.csv',
|
| 61 |
'NA': 'ibtracs.NA.list.v04r01.csv',
|
|
|
|
| 137 |
year = str(int(year)-1)
|
| 138 |
data[year][month-1] = anom
|
| 139 |
with open(output_file, 'w', newline='') as f:
|
|
|
|
| 140 |
writer = csv.writer(f)
|
| 141 |
writer.writerow(['Year','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'])
|
| 142 |
for year in sorted(data.keys()):
|
|
|
|
| 221 |
else:
|
| 222 |
return 'Neutral'
|
| 223 |
|
| 224 |
+
# -----------------------------
|
| 225 |
+
# Regression Functions
|
| 226 |
+
# -----------------------------
|
| 227 |
def perform_wind_regression(start_year, start_month, end_year, end_month):
|
| 228 |
start_date = datetime(start_year, start_month, 1)
|
| 229 |
end_date = datetime(end_year, end_month, 28)
|
|
|
|
| 263 |
p_value = model.pvalues['ONI']
|
| 264 |
return f"Longitude Regression: β1={beta_1:.4f}, Odds Ratio={exp_beta_1:.4f}, P-value={p_value:.4f}"
|
| 265 |
|
| 266 |
+
# -----------------------------
|
| 267 |
+
# IBTrACS Data Loading
|
| 268 |
+
# -----------------------------
|
| 269 |
def load_ibtracs_data():
|
| 270 |
ibtracs_data = {}
|
| 271 |
for basin, filename in BASIN_FILES.items():
|
|
|
|
| 289 |
|
| 290 |
ibtracs = load_ibtracs_data()
|
| 291 |
|
| 292 |
+
# -----------------------------
|
| 293 |
+
# Load & Process Data
|
| 294 |
+
# -----------------------------
|
| 295 |
update_oni_data()
|
| 296 |
oni_data, typhoon_data = load_data(ONI_DATA_PATH, TYPHOON_DATA_PATH)
|
| 297 |
oni_long = process_oni_data(oni_data)
|
| 298 |
typhoon_max = process_typhoon_data(typhoon_data)
|
| 299 |
merged_data = merge_data(oni_long, typhoon_max)
|
| 300 |
|
| 301 |
+
# -----------------------------
|
| 302 |
+
# Visualization Functions
|
| 303 |
+
# -----------------------------
|
| 304 |
def generate_typhoon_tracks(filtered_data, typhoon_search):
|
| 305 |
fig = go.Figure()
|
| 306 |
for sid in filtered_data['SID'].unique():
|
|
|
|
| 401 |
for sid in unique_storms:
|
| 402 |
storm_data = typhoon_data[typhoon_data['SID']==sid]
|
| 403 |
name = storm_data['NAME'].iloc[0] if pd.notnull(storm_data['NAME'].iloc[0]) else "Unnamed"
|
| 404 |
+
basin = storm_data['SID'].iloc[0][:2] # First 2 characters often denote basin
|
| 405 |
storm_oni = filtered_data[filtered_data['SID']==sid]['ONI'].iloc[0]
|
| 406 |
color = 'red' if storm_oni>=0.5 else ('blue' if storm_oni<=-0.5 else 'green')
|
| 407 |
fig.add_trace(go.Scattergeo(
|
| 408 |
lon=storm_data['LON'], lat=storm_data['LAT'], mode='lines',
|
| 409 |
+
name=f"{name} ({basin})",
|
| 410 |
line=dict(width=1.5, color=color), hoverinfo="name"
|
| 411 |
))
|
| 412 |
if typhoon_search:
|
|
|
|
| 416 |
storm_data = typhoon_data[typhoon_data['SID']==sid]
|
| 417 |
fig.add_trace(go.Scattergeo(
|
| 418 |
lon=storm_data['LON'], lat=storm_data['LAT'], mode='lines+markers',
|
| 419 |
+
name=f"MATCHED: {storm_data['NAME'].iloc[0]}",
|
| 420 |
line=dict(width=3, color='yellow'),
|
| 421 |
marker=dict(size=5), hoverinfo="name"
|
| 422 |
))
|
|
|
|
| 438 |
)
|
| 439 |
fig.add_annotation(
|
| 440 |
x=0.02, y=0.98, xref="paper", yref="paper",
|
| 441 |
+
text="Red: El Niño, Blue: La Nina, Green: Neutral",
|
| 442 |
showarrow=False, align="left",
|
| 443 |
bgcolor="rgba(255,255,255,0.8)"
|
| 444 |
)
|
|
|
|
| 484 |
return 'Tropical Storm', atlantic_standard['Tropical Storm']['hex']
|
| 485 |
return 'Tropical Depression', atlantic_standard['Tropical Depression']['hex']
|
| 486 |
|
| 487 |
+
# -----------------------------
|
| 488 |
+
# Updated TSNE Cluster Function with Mean Curves
|
| 489 |
+
# -----------------------------
|
| 490 |
def update_route_clusters(start_year, start_month, end_year, end_month, enso_value, season):
|
| 491 |
try:
|
| 492 |
+
# Merge raw typhoon data with ONI so each storm has multiple observations.
|
| 493 |
raw_data = typhoon_data.copy()
|
| 494 |
raw_data['Year'] = raw_data['ISO_TIME'].dt.year
|
| 495 |
raw_data['Month'] = raw_data['ISO_TIME'].dt.strftime('%m')
|
|
|
|
| 507 |
merged_raw = merged_raw[merged_raw['ENSO_Phase'] == enso_value.capitalize()]
|
| 508 |
logging.info(f"Total points after ENSO filtering: {merged_raw.shape[0]}")
|
| 509 |
|
| 510 |
+
# Regional filtering for Western Pacific
|
| 511 |
wp_data = merged_raw[(merged_raw['LON'] >= 100) & (merged_raw['LON'] <= 180) &
|
| 512 |
(merged_raw['LAT'] >= 0) & (merged_raw['LAT'] <= 40)]
|
| 513 |
logging.info(f"Total points after WP regional filtering: {wp_data.shape[0]}")
|
|
|
|
| 515 |
logging.info("WP regional filter returned no data; using all filtered data.")
|
| 516 |
wp_data = merged_raw
|
| 517 |
|
| 518 |
+
# Group by storm ID so each storm has multiple observations
|
| 519 |
all_storms_data = []
|
| 520 |
for sid, group in wp_data.groupby('SID'):
|
| 521 |
group = group.sort_values('ISO_TIME')
|
|
|
|
| 524 |
lons = group['LON'].astype(float).values
|
| 525 |
if len(lons) < 2:
|
| 526 |
continue
|
| 527 |
+
# Also extract wind and pressure curves
|
| 528 |
wind = group['USA_WIND'].astype(float).values if 'USA_WIND' in group.columns else None
|
| 529 |
pres = group['USA_PRES'].astype(float).values if 'USA_PRES' in group.columns else None
|
| 530 |
all_storms_data.append((sid, lons, lats, times, wind, pres))
|
|
|
|
| 532 |
if not all_storms_data:
|
| 533 |
return go.Figure(), go.Figure(), make_subplots(rows=2, cols=1), "No valid storms for clustering."
|
| 534 |
|
| 535 |
+
# Interpolate each storm's route, wind, and pressure to a common length
|
| 536 |
max_length = max(len(item[1]) for item in all_storms_data)
|
| 537 |
route_vectors = []
|
| 538 |
wind_curves = []
|
|
|
|
| 552 |
continue
|
| 553 |
route_vectors.append(route_vector)
|
| 554 |
storm_ids.append(sid)
|
| 555 |
+
# Interpolate wind and pressure if available
|
| 556 |
if wind is not None and len(wind) >= 2:
|
| 557 |
try:
|
| 558 |
wind_interp = interp1d(t, wind, kind='linear', fill_value='extrapolate')(t_new)
|
|
|
|
| 583 |
tsne = TSNE(n_components=2, random_state=42, verbose=1)
|
| 584 |
tsne_results = tsne.fit_transform(route_vectors)
|
| 585 |
|
| 586 |
+
# Dynamic DBSCAN: choose eps to yield roughly 5 to 20 clusters
|
| 587 |
selected_labels = None
|
| 588 |
selected_eps = None
|
| 589 |
for eps in np.linspace(1.0, 10.0, 91):
|
| 590 |
dbscan = DBSCAN(eps=eps, min_samples=3)
|
| 591 |
labels = dbscan.fit_predict(tsne_results)
|
| 592 |
clusters = set(labels) - {-1}
|
| 593 |
+
if 5 <= len(clusters) <= 20:
|
|
|
|
| 594 |
selected_labels = labels
|
| 595 |
selected_eps = eps
|
| 596 |
break
|
|
|
|
| 598 |
selected_eps = 5.0
|
| 599 |
dbscan = DBSCAN(eps=selected_eps, min_samples=3)
|
| 600 |
selected_labels = dbscan.fit_predict(tsne_results)
|
| 601 |
+
logging.info(f"Selected DBSCAN eps: {selected_eps:.2f} yielding {len(set(selected_labels)-{-1})} clusters.")
|
| 602 |
|
| 603 |
# TSNE scatter plot
|
| 604 |
fig_tsne = go.Figure()
|
|
|
|
| 628 |
yaxis_title="t-SNE Dim 2"
|
| 629 |
)
|
| 630 |
|
| 631 |
+
# For each cluster, compute mean route, and compute mean wind and pressure curves along normalized route index.
|
| 632 |
fig_routes = go.Figure()
|
| 633 |
+
cluster_stats = [] # To hold mean curves per cluster
|
| 634 |
for i, label in enumerate(unique_labels):
|
| 635 |
indices = np.where(selected_labels == label)[0]
|
| 636 |
cluster_ids = [storm_ids[j] for j in indices]
|
|
|
|
| 646 |
line=dict(width=4, color=colors[i % len(colors)]),
|
| 647 |
name=f"Cluster {label} Mean Route"
|
| 648 |
))
|
| 649 |
+
# Retrieve raw wind and pressure curves for storms in this cluster
|
| 650 |
+
cluster_winds = wind_curves[indices, :]
|
| 651 |
+
cluster_pres = pres_curves[indices, :]
|
| 652 |
+
mean_wind_curve = np.nanmean(cluster_winds, axis=0)
|
| 653 |
+
mean_pres_curve = np.nanmean(cluster_pres, axis=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
cluster_stats.append((label, mean_wind_curve, mean_pres_curve))
|
| 655 |
|
| 656 |
+
# Create a cluster stats plot with curves vs normalized route index (0 to 1)
|
| 657 |
x_axis = np.linspace(0, 1, max_length)
|
| 658 |
fig_stats = make_subplots(rows=2, cols=1, shared_xaxes=True,
|
| 659 |
subplot_titles=("Mean Wind Speed (knots)", "Mean MSLP (hPa)"))
|
|
|
|
| 687 |
logging.error(f"Error in TSNE clustering: {e}")
|
| 688 |
return go.Figure(), go.Figure(), make_subplots(rows=2, cols=1), f"Error in TSNE clustering: {e}"
|
| 689 |
|
| 690 |
+
# -----------------------------
|
| 691 |
+
# Animation Functions Using Processed CSV & Stock Map
|
| 692 |
+
# -----------------------------
|
| 693 |
def generate_track_video_from_csv(year, storm_id, standard):
|
| 694 |
storm_df = typhoon_data[typhoon_data['SID'] == storm_id].copy()
|
| 695 |
if storm_df.empty:
|
|
|
|
| 704 |
else:
|
| 705 |
winds = np.full(len(lats), np.nan)
|
| 706 |
storm_name = storm_df['NAME'].iloc[0]
|
| 707 |
+
basin = storm_df['SID'].iloc[0][:2] # Use first 2 characters as basin code
|
| 708 |
season = storm_df['SEASON'].iloc[0]
|
| 709 |
|
| 710 |
min_lat, max_lat = np.min(lats), np.max(lats)
|
|
|
|
| 721 |
ax.coastlines(resolution='50m', color='black', linewidth=1)
|
| 722 |
gl = ax.gridlines(draw_labels=True, color='gray', alpha=0.4, linestyle='--')
|
| 723 |
gl.top_labels = gl.right_labels = False
|
| 724 |
+
ax.set_title(f"{year} {storm_name} ({basin}) - {season}", fontsize=14)
|
| 725 |
|
| 726 |
line, = ax.plot([], [], transform=ccrs.PlateCarree(), color='blue', linewidth=2)
|
| 727 |
point, = ax.plot([], [], 'o', markersize=8, transform=ccrs.PlateCarree())
|
| 728 |
date_text = ax.text(0.02, 0.02, '', transform=ax.transAxes, fontsize=10,
|
| 729 |
bbox=dict(facecolor='white', alpha=0.8))
|
| 730 |
+
# Display storm name and basin in a dynamic sidebar
|
| 731 |
storm_info_text = fig.text(0.70, 0.60, '', fontsize=10,
|
| 732 |
bbox=dict(facecolor='white', alpha=0.8, boxstyle='round,pad=0.5'))
|
| 733 |
|
|
|
|
| 754 |
point.set_color(color)
|
| 755 |
dt_str = pd.to_datetime(times[frame]).strftime('%Y-%m-%d %H:%M')
|
| 756 |
date_text.set_text(dt_str)
|
| 757 |
+
info_str = (f"Name: {storm_name}\nBasin: {basin}\nDate: {dt_str}\nWind: {wind_speed:.1f} kt\nCategory: {category}")
|
|
|
|
|
|
|
|
|
|
| 758 |
storm_info_text.set_text(info_str)
|
| 759 |
return line, point, date_text, storm_info_text
|
| 760 |
|
|
|
|
| 772 |
storm_id = typhoon.split('(')[-1].strip(')')
|
| 773 |
return generate_track_video_from_csv(year, storm_id, standard)
|
| 774 |
|
| 775 |
+
# -----------------------------
|
| 776 |
+
# Typhoon Options Update Functions
|
| 777 |
+
# -----------------------------
|
| 778 |
basin_to_prefix = {
|
| 779 |
"All Basins": "all",
|
| 780 |
"NA - North Atlantic": "NA",
|
|
|
|
| 838 |
logging.error(f"Error in update_typhoon_options_anim: {e}")
|
| 839 |
return gr.update(choices=[], value=None)
|
| 840 |
|
| 841 |
+
# -----------------------------
|
| 842 |
+
# Gradio Interface
|
| 843 |
+
# -----------------------------
|
| 844 |
with gr.Blocks(title="Typhoon Analysis Dashboard") as demo:
|
| 845 |
gr.Markdown("# Typhoon Analysis Dashboard")
|
| 846 |
|
|
|
|
| 855 |
- **Wind Analysis**: Examine wind speed vs ONI relationships.
|
| 856 |
- **Pressure Analysis**: Analyze pressure vs ONI relationships.
|
| 857 |
- **Longitude Analysis**: Study typhoon generation longitude vs ONI.
|
| 858 |
+
- **Path Animation**: View animated storm tracks on a free stock world map (centered at 180°) with a dynamic sidebar that shows the typhoon name and basin.
|
| 859 |
+
- **TSNE Cluster**: Perform t-SNE clustering on WP storm routes using raw merged typhoon+ONI data with detailed error management.
|
| 860 |
+
Mean routes and evolving curves (wind and pressure vs. normalized route index) are computed.
|
|
|
|
| 861 |
""")
|
| 862 |
|
| 863 |
with gr.Tab("Track Visualization"):
|
|
|
|
| 922 |
outputs=[regression_plot, slopes_text, lon_regression_results])
|
| 923 |
|
| 924 |
with gr.Tab("Tropical Cyclone Path Animation"):
|
| 925 |
+
# Basin selection removed. Always use All Basins.
|
| 926 |
with gr.Row():
|
| 927 |
year_dropdown = gr.Dropdown(label="Year", choices=[str(y) for y in range(1950,2025)], value="2000")
|
| 928 |
+
# Remove basin dropdown and set it internally to "all"
|
| 929 |
with gr.Row():
|
| 930 |
typhoon_dropdown = gr.Dropdown(label="Tropical Cyclone")
|
| 931 |
standard_dropdown = gr.Dropdown(label="Classification Standard", choices=['atlantic','taiwan'], value='atlantic')
|
|
|
|
| 933 |
path_video = gr.Video(label="Tropical Cyclone Path Animation", format="mp4", interactive=False, elem_id="path_video")
|
| 934 |
animation_info = gr.Markdown("""
|
| 935 |
### Animation Instructions
|
| 936 |
+
1. Select a year (data is from your processed CSV, using all basins).
|
| 937 |
2. Choose a tropical cyclone from the populated list.
|
| 938 |
3. Select a classification standard (Atlantic or Taiwan).
|
| 939 |
4. Click "Generate Animation".
|
| 940 |
+
5. The animation displays the storm track on a free stock world map (centered at 180°) with a dynamic sidebar.
|
| 941 |
+
The sidebar shows the storm name and basin.
|
| 942 |
""")
|
| 943 |
+
year_dropdown.change(fn=update_typhoon_options_anim, inputs=[year_dropdown, gr.State("dummy")], outputs=typhoon_dropdown)
|
|
|
|
| 944 |
animate_btn.click(fn=simplified_track_video,
|
| 945 |
+
inputs=[year_dropdown, gr.Textbox.update(value="All Basins"), typhoon_dropdown, standard_dropdown],
|
| 946 |
outputs=path_video)
|
| 947 |
|
| 948 |
with gr.Tab("TSNE Cluster"):
|