Mahmudm commited on
Commit
d3fc047
·
verified ·
1 Parent(s): f31176e

Upload 23 files

Browse files
app.py CHANGED
@@ -16,7 +16,9 @@ from ash_animator import create_grid
16
 
17
  pn.extension()
18
 
19
- MEDIA_DIR = "media"
 
 
20
  os.makedirs(MEDIA_DIR, exist_ok=True)
21
 
22
  # Logging setup
@@ -69,12 +71,20 @@ def process_zip(event=None):
69
  status.object = "✅ ZIP uploaded and saved."
70
  else:
71
  zip_path = os.path.join(MEDIA_DIR, "default_model.zip")
 
 
72
  if not os.path.exists(zip_path):
73
  status.object = "❌ No ZIP uploaded and default_model.zip not found."
74
  return
75
  status.object = "📦 Using default_model.zip"
76
 
77
- output_dir = os.path.join("./", "ash_output")
 
 
 
 
 
 
78
  shutil.rmtree(output_dir, ignore_errors=True)
79
  os.makedirs(output_dir, exist_ok=True)
80
 
@@ -85,6 +95,10 @@ def process_zip(event=None):
85
  # animator_obj["3d"] = [xr.open_dataset(fp).load()
86
  # for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc")))]
87
 
 
 
 
 
88
  animator_obj["3d"] = []
89
  for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc"))):
90
  with xr.open_dataset(fp) as ds:
@@ -125,7 +139,12 @@ def restore_previous_session():
125
  with open(state_file) as f:
126
  zip_path = f.read().strip()
127
  if os.path.exists(zip_path):
128
- output_dir = os.path.join("./", "ash_output")
 
 
 
 
 
129
 
130
  animator_obj["3d"] = []
131
  for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc"))):
@@ -249,35 +268,99 @@ def human_readable_size(size):
249
  size /= 1024
250
  return f"{size:.1f} TB"
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def generate_output_gallery(base_folder):
253
- grouped = defaultdict(lambda: defaultdict(list))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  for root, _, files in os.walk(os.path.join(MEDIA_DIR, base_folder)):
255
- for file in files:
256
- ext = os.path.splitext(file)[1].lower()
257
- subfolder = os.path.relpath(root, MEDIA_DIR)
258
- grouped[subfolder][ext].append(os.path.join(root, file))
259
-
260
- folder_tabs = []
261
- for subfolder, ext_files in sorted(grouped.items()):
262
- type_tabs = []
263
- for ext, paths in sorted(ext_files.items()):
264
- previews = []
265
- for path in sorted(paths, key=os.path.getmtime, reverse=True):
266
- size = human_readable_size(os.path.getsize(path))
267
- mod = datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d %H:%M")
268
- title = f"**{os.path.basename(path)}**\\n_{size}, {mod}_"
269
- download = pn.widgets.FileDownload(label="⬇", file=path, filename=os.path.basename(path), width=60)
270
- if ext in [".gif", ".png", ".jpg", ".jpeg"]:
271
- preview = pn.pane.Image(path, width=320)
272
- else:
273
- with open(path, "r", errors="ignore") as f:
274
- content = f.read(2048)
275
- preview = pn.pane.PreText(content, width=320)
276
- card = pn.Card(pn.pane.Markdown(title), preview, pn.Row(download), width=360)
277
- previews.append(card)
278
- type_tabs.append((ext.upper(), pn.GridBox(*previews, ncols=2)))
279
- folder_tabs.append((subfolder, pn.Tabs(*type_tabs)))
280
- return pn.Tabs(*folder_tabs)
 
 
 
 
 
 
 
 
 
 
 
281
 
282
  def update_media_tabs():
283
  media_tab_2d.objects[:] = [generate_output_gallery("2D")]
@@ -291,6 +374,418 @@ media_tab = pn.Tabs(
291
  ("3D Outputs", media_tab_3d)
292
  )
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  tab3d = pn.Column(
295
  threshold_slider_3d, zoom_slider_3d, fps_slider_3d, Altitude_slider, cmap_select_3d,
296
  pn.widgets.Button(name="🎞 Generate animation at selected altitude level", button_type="primary", on_click=lambda e: tab3d.append(plot_z_level())),
@@ -306,13 +801,75 @@ tab2d = pn.Column(
306
  pn.widgets.Button(name="💧 Animate Wet Deposition Rate", button_type="primary", on_click=lambda e: tab2d.append(plot_2d_field("wet_deposition_rate"))),
307
  )
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  tabs = pn.Tabs(
310
  ("🧱 3D Field", tab3d),
311
  ("🌍 2D Field", tab2d),
312
- ("📁 Media Viewer", media_tab)
 
313
  )
314
 
315
- sidebar = pn.Column("## 🌋 NAME Ash Visualizer", file_input, process_button, reset_button, download_button, log_link, status)
 
 
 
 
 
 
 
 
316
 
317
  restore_previous_session()
318
 
@@ -320,4 +877,5 @@ pn.template.FastListTemplate(
320
  title="NAME Visualizer Dashboard",
321
  sidebar=sidebar,
322
  main=[tabs],
323
- ).servable()
 
 
16
 
17
  pn.extension()
18
 
19
+ import tempfile
20
+
21
+ MEDIA_DIR = os.environ.get("NAME_MEDIA_DIR", os.path.join(tempfile.gettempdir(), "name_media"))
22
  os.makedirs(MEDIA_DIR, exist_ok=True)
23
 
24
  # Logging setup
 
71
  status.object = "✅ ZIP uploaded and saved."
72
  else:
73
  zip_path = os.path.join(MEDIA_DIR, "default_model.zip")
74
+ if not os.path.exists(zip_path):
75
+ zip_path = "default_model.zip" # fallback to local directory
76
  if not os.path.exists(zip_path):
77
  status.object = "❌ No ZIP uploaded and default_model.zip not found."
78
  return
79
  status.object = "📦 Using default_model.zip"
80
 
81
+
82
+ try:
83
+ output_dir = os.path.join("./", "ash_output")
84
+ os.makedirs(output_dir, exist_ok=True)
85
+ except PermissionError:
86
+ output_dir = os.path.join(tempfile.gettempdir(), "name_output")
87
+ os.makedirs(output_dir, exist_ok=True)
88
  shutil.rmtree(output_dir, ignore_errors=True)
89
  os.makedirs(output_dir, exist_ok=True)
90
 
 
95
  # animator_obj["3d"] = [xr.open_dataset(fp).load()
96
  # for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc")))]
97
 
98
+ # animator_obj["3d"] = []
99
+ # for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc"))):
100
+ # with xr.open_dataset(fp) as ds:
101
+ # animator_obj["3d"].append(ds.load())
102
  animator_obj["3d"] = []
103
  for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc"))):
104
  with xr.open_dataset(fp) as ds:
 
139
  with open(state_file) as f:
140
  zip_path = f.read().strip()
141
  if os.path.exists(zip_path):
142
+ try:
143
+ output_dir = os.path.join("./", "ash_output")
144
+ os.makedirs(output_dir, exist_ok=True)
145
+ except PermissionError:
146
+ output_dir = os.path.join(tempfile.gettempdir(), "name_output")
147
+ os.makedirs(output_dir, exist_ok=True)
148
 
149
  animator_obj["3d"] = []
150
  for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc"))):
 
268
  size /= 1024
269
  return f"{size:.1f} TB"
270
 
271
+ # def generate_output_gallery(base_folder):
272
+ # grouped = defaultdict(lambda: defaultdict(list))
273
+ # for root, _, files in os.walk(os.path.join(MEDIA_DIR, base_folder)):
274
+ # for file in files:
275
+ # ext = os.path.splitext(file)[1].lower()
276
+ # subfolder = os.path.relpath(root, MEDIA_DIR)
277
+ # grouped[subfolder][ext].append(os.path.join(root, file))
278
+
279
+ # folder_tabs = []
280
+ # for subfolder, ext_files in sorted(grouped.items()):
281
+ # type_tabs = []
282
+ # for ext, paths in sorted(ext_files.items()):
283
+ # previews = []
284
+ # for path in sorted(paths, key=os.path.getmtime, reverse=True):
285
+ # size = human_readable_size(os.path.getsize(path))
286
+ # mod = datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d %H:%M")
287
+ # title = f"**{os.path.basename(path)}**\\n_{size}, {mod}_"
288
+ # download = pn.widgets.FileDownload(label="⬇", file=path, filename=os.path.basename(path), width=60)
289
+ # if ext in [".gif", ".png", ".jpg", ".jpeg"]:
290
+ # preview = pn.pane.Image(path, width=320)
291
+ # else:
292
+ # with open(path, "r", errors="ignore") as f:
293
+ # content = f.read(2048)
294
+ # preview = pn.pane.PreText(content, width=320)
295
+ # card = pn.Card(pn.pane.Markdown(title), preview, pn.Row(download), width=360)
296
+ # previews.append(card)
297
+ # type_tabs.append((ext.upper(), pn.GridBox(*previews, ncols=2)))
298
+ # folder_tabs.append((subfolder, pn.Tabs(*type_tabs)))
299
+ # return pn.Tabs(*folder_tabs)
300
+
301
+
302
  def generate_output_gallery(base_folder):
303
+ preview_container = pn.Column(width=640, height=550)
304
+ preview_container.append(pn.pane.Markdown("👈 Click a thumbnail to preview"))
305
+ folder_cards = []
306
+
307
+ def make_preview(file_path):
308
+ ext = os.path.splitext(file_path)[1].lower()
309
+ title = pn.pane.Markdown(f"### {os.path.basename(file_path)}", width=640)
310
+ download_button = pn.widgets.FileDownload(file=file_path, filename=os.path.basename(file_path),
311
+ label="⬇ Download", button_type="success", width=150)
312
+
313
+ if ext in [".gif", ".png", ".jpg", ".jpeg"]:
314
+ content = pn.pane.Image(file_path, width=640, height=450, sizing_mode="fixed")
315
+ else:
316
+ try:
317
+ with open(file_path, 'r', errors="ignore") as f:
318
+ text = f.read(2048)
319
+ content = pn.pane.PreText(text, width=640, height=450)
320
+ except:
321
+ content = pn.pane.Markdown("*Unable to preview this file.*")
322
+
323
+ return pn.Column(title, content, download_button)
324
+
325
+ grouped = defaultdict(list)
326
  for root, _, files in os.walk(os.path.join(MEDIA_DIR, base_folder)):
327
+ for file in sorted(files):
328
+ full_path = os.path.join(root, file)
329
+ if not os.path.exists(full_path):
330
+ continue
331
+ rel_folder = os.path.relpath(root, os.path.join(MEDIA_DIR, base_folder))
332
+ grouped[rel_folder].append(full_path)
333
+
334
+ for folder, file_paths in sorted(grouped.items()):
335
+ thumbnails = []
336
+ for full_path in file_paths:
337
+ filename = os.path.basename(full_path)
338
+ ext = os.path.splitext(full_path)[1].lower()
339
+
340
+ if ext in [".gif", ".png", ".jpg", ".jpeg"]:
341
+ img = pn.pane.Image(full_path, width=140, height=100)
342
+ else:
343
+ img = pn.pane.Markdown("📄", width=140, height=100)
344
+
345
+ view_button = pn.widgets.Button(name="👁", width=40, height=30, button_type="primary")
346
+
347
+ def click_handler(path=full_path):
348
+ def inner_click(event):
349
+ preview_container[:] = [make_preview(path)]
350
+ return inner_click
351
+
352
+ view_button.on_click(click_handler())
353
+
354
+ overlay = pn.Column(pn.Row(pn.Spacer(width=90), view_button), img, width=160)
355
+ label_md = pn.pane.Markdown(f"**{filename}**", width=140, height=35)
356
+ thumb_card = pn.Column(overlay, label_md, width=160)
357
+ thumbnails.append(thumb_card)
358
+
359
+ folder_card = pn.Card(pn.GridBox(*thumbnails, ncols=2), title=f"📁 {folder}", width=400, collapsible=True)
360
+ folder_cards.append(folder_card)
361
+
362
+ folder_scroll = pn.Column(*folder_cards, scroll=True, height=600, width=420)
363
+ return pn.Row(preview_container, pn.Spacer(width=20), folder_scroll)
364
 
365
  def update_media_tabs():
366
  media_tab_2d.objects[:] = [generate_output_gallery("2D")]
 
374
  ("3D Outputs", media_tab_3d)
375
  )
376
 
377
+
378
+ tab3d = pn.Column(
379
+ threshold_slider_3d, zoom_slider_3d, fps_slider_3d, Altitude_slider, cmap_select_3d,
380
+ pn.widgets.Button(name="🎞 Generate animation at selected altitude level", button_type="primary", on_click=lambda e: tab3d.append(plot_z_level())),
381
+ pn.widgets.Button(name="📈 Generate vertical profile animation at time index", button_type="primary", on_click=lambda e: tab3d.append(plot_vertical_profile())),
382
+ pn.widgets.Button(name="📊 Generate all altitude level animations", button_type="primary", on_click=lambda e: tab3d.append(animate_all_altitude_profiles())),
383
+ pn.widgets.Button(name="🖼 Export all animation frames as JPG", button_type="primary", on_click=lambda e: tab3d.append(export_jpg_frames())),
384
+ )
385
+
386
+ tab2d = pn.Column(
387
+ threshold_slider_2d, zoom_slider_2d, fps_slider_2d, cmap_select_2d,
388
+ pn.widgets.Button(name="🌫 Animate Air Concentration", button_type="primary", on_click=lambda e: tab2d.append(plot_2d_field("air_concentration"))),
389
+ pn.widgets.Button(name="🌧 Animate Dry Deposition Rate", button_type="primary", on_click=lambda e: tab2d.append(plot_2d_field("dry_deposition_rate"))),
390
+ pn.widgets.Button(name="💧 Animate Wet Deposition Rate", button_type="primary", on_click=lambda e: tab2d.append(plot_2d_field("wet_deposition_rate"))),
391
+ )
392
+
393
+ help_tab = pn.Column(pn.pane.Markdown("""
394
+ ## ❓ How to Use the NAME Ash Visualizer
395
+
396
+ This dashboard allows users to upload and visualize outputs from the NAME ash dispersion model.
397
+
398
+ ### 🧭 Workflow
399
+ 1. **Upload ZIP** containing NetCDF files from the NAME model.
400
+ 2. Use **3D and 2D tabs** to configure and generate animations.
401
+ 3. Use **Media Viewer** to preview and download results.
402
+
403
+ ### 🧳 ZIP Structure
404
+ ```
405
+ ## 🗂 How Uploaded ZIP is Processed
406
+
407
+ ```text
408
+ ┌────────────────────────────────────────────┐
409
+ │ Uploaded ZIP (.zip) │
410
+ │ (e.g. Taal_273070_20200112_scenario_*.zip)│
411
+ └────────────────────────────────────────────┘
412
+
413
+
414
+ ┌───────────────────────────────┐
415
+ │ Contains: raw .txt outputs │
416
+ │ - AQOutput_3DField_*.txt │
417
+ │ - AQOutput_horizontal_*.txt │
418
+ └───────────────────────────────┘
419
+
420
+
421
+ ┌────────────────────────────────────────┐
422
+ │ NAMEDataProcessor.batch_process_zip()│
423
+ └────────────────────────────────────────┘
424
+
425
+
426
+ ┌─────────────────────────────┐
427
+ │ Converts to NetCDF files │
428
+ │ - ash_output/3D/*.nc │
429
+ │ - ash_output/horizontal/*.nc │
430
+ └─────────────────────────────┘
431
+
432
+
433
+ ┌─────────────────────────────────────┐
434
+ │ View & animate in 3D/2D tabs │
435
+ │ Download results in Media Viewer │
436
+ └─────────────────────────────────────┘
437
+
438
+ ```
439
+
440
+ ### 📢 Tips
441
+ - Reset the app with 🔄 if needed.
442
+ - View logs if an error occurs.
443
+ - Outputs are temporary per session.
444
+ """, sizing_mode="stretch_width", width=800))
445
+
446
+ tabs = pn.Tabs(
447
+ ("🧱 3D Field", tab3d),
448
+ ("🌍 2D Field", tab2d),
449
+ ("📁 Media Viewer", media_tab),
450
+ ("❓ Help", help_tab)
451
+ )
452
+
453
+ sidebar = pn.Column(
454
+ pn.pane.Markdown("## 🌋 NAME Ash Visualizer", sizing_mode="stretch_width"),
455
+ pn.Card(pn.Column(file_input, process_button, reset_button, sizing_mode="stretch_width"),
456
+ title="📂 File Upload & Processing", collapsible=True, sizing_mode="stretch_width"),
457
+ pn.Card(pn.Column(download_button, log_link, sizing_mode="stretch_width"),
458
+ title="📁 Downloads & Logs", collapsible=True, sizing_mode="stretch_width"),
459
+ pn.Card(status, title="📢 Status", collapsible=True, sizing_mode="stretch_width"),
460
+ sizing_mode="stretch_width", width=300
461
+ )
462
+
463
+ restore_previous_session()
464
+
465
+ pn.template.FastListTemplate(
466
+ title="NAME Visualizer Dashboard",
467
+ sidebar=sidebar,
468
+ main=[tabs],
469
+ ).servable()
470
+
471
+ '''
472
+ import os
473
+ import glob
474
+ import shutil
475
+ import io
476
+ import logging
477
+ import panel as pn
478
+ import xarray as xr
479
+ import numpy as np
480
+ from datetime import datetime
481
+ from types import SimpleNamespace
482
+ from collections import defaultdict
483
+ from ash_animator.converter import NAMEDataProcessor
484
+ from ash_animator.plot_3dfield_data import Plot_3DField_Data
485
+ from ash_animator.plot_horizontal_data import Plot_Horizontal_Data
486
+ from ash_animator import create_grid
487
+ import tempfile
488
+
489
+ pn.extension()
490
+
491
+ MEDIA_DIR = os.environ.get("NAME_MEDIA_DIR", os.path.join(tempfile.gettempdir(), "name_media"))
492
+ os.makedirs(MEDIA_DIR, exist_ok=True)
493
+
494
+ LOG_FILE = os.path.join(MEDIA_DIR, "app_errors.log")
495
+ logging.basicConfig(filename=LOG_FILE, level=logging.ERROR, format="%(asctime)s - %(levelname)s - %(message)s")
496
+
497
+ animator_obj = {}
498
+
499
+ file_input = pn.widgets.FileInput(accept=".zip")
500
+ process_button = pn.widgets.Button(name="📦 Process ZIP", button_type="primary")
501
+ reset_button = pn.widgets.Button(name="🔄 Reset App", button_type="danger")
502
+ status = pn.pane.Markdown("### Upload a NAME Model ZIP to begin")
503
+
504
+ download_button = pn.widgets.FileDownload(
505
+ label="⬇️ Download All Exports", filename="all_exports.zip", button_type="success",
506
+ callback=lambda: io.BytesIO(open(shutil.make_archive(os.path.join(MEDIA_DIR, "all_exports").replace(".zip", ""), "zip", MEDIA_DIR), 'rb').read())
507
+ )
508
+
509
+ log_link = pn.widgets.FileDownload(label="🪵 View Error Log", file=LOG_FILE, filename="app_errors.log", button_type="warning")
510
+
511
+ threshold_slider_3d = pn.widgets.FloatSlider(name='3D Threshold', start=0.0, end=1.0, step=0.05, value=0.1)
512
+ zoom_slider_3d = pn.widgets.IntSlider(name='3D Zoom Level', start=1, end=20, value=19)
513
+ cmap_select_3d = pn.widgets.Select(name='3D Colormap', options=["rainbow", "viridis", "plasma"])
514
+ fps_slider_3d = pn.widgets.IntSlider(name='3D FPS', start=1, end=10, value=2)
515
+ Altitude_slider = pn.widgets.IntSlider(name='Define Ash Altitude', start=1, end=15, value=1)
516
+
517
+ threshold_slider_2d = pn.widgets.FloatSlider(name='2D Threshold', start=0.0, end=1.0, step=0.01, value=0.005)
518
+ zoom_slider_2d = pn.widgets.IntSlider(name='2D Zoom Level', start=1, end=20, value=19)
519
+ fps_slider_2d = pn.widgets.IntSlider(name='2D FPS', start=1, end=10, value=2)
520
+ cmap_select_2d = pn.widgets.Select(name='2D Colormap', options=["rainbow", "viridis", "plasma"])
521
+
522
+ def process_zip(event=None):
523
+ if file_input.value:
524
+ zip_path = os.path.join(MEDIA_DIR, file_input.filename)
525
+ with open(zip_path, "wb") as f:
526
+ f.write(file_input.value)
527
+ status.object = "✅ ZIP uploaded and saved."
528
+ else:
529
+ zip_path = os.path.join(MEDIA_DIR, "default_model.zip")
530
+ if not os.path.exists(zip_path):
531
+ zip_path = "default_model.zip"
532
+ if not os.path.exists(zip_path):
533
+ status.object = "❌ No ZIP uploaded and default_model.zip not found."
534
+ return
535
+ status.object = "📦 Using default_model.zip"
536
+
537
+ try:
538
+ output_dir = os.path.join("./", "ash_output")
539
+ os.makedirs(output_dir, exist_ok=True)
540
+ except PermissionError:
541
+ output_dir = os.path.join(tempfile.gettempdir(), "name_output")
542
+ os.makedirs(output_dir, exist_ok=True)
543
+ shutil.rmtree(output_dir, ignore_errors=True)
544
+ os.makedirs(output_dir, exist_ok=True)
545
+
546
+ try:
547
+ processor = NAMEDataProcessor(output_root=output_dir)
548
+ processor.batch_process_zip(zip_path)
549
+
550
+ animator_obj["3d"] = [xr.open_dataset(fp).load() for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc")))]
551
+ animator_obj["2d"] = [xr.open_dataset(fp).load() for fp in sorted(glob.glob(os.path.join(output_dir, "horizontal", "*.nc")))]
552
+
553
+ with open(os.path.join(MEDIA_DIR, "last_run.txt"), "w") as f:
554
+ f.write(zip_path)
555
+
556
+ status.object += f" | ✅ Loaded 3D: {len(animator_obj['3d'])} & 2D: {len(animator_obj['2d'])}"
557
+ update_media_tabs()
558
+ except Exception as e:
559
+ logging.exception("Error during ZIP processing")
560
+ status.object = f"❌ Processing failed: {e}"
561
+
562
+ def reset_app(event=None):
563
+ animator_obj.clear()
564
+ file_input.value = None
565
+ status.object = "🔄 App has been reset."
566
+ for folder in ["ash_output", "2D", "3D"]:
567
+ shutil.rmtree(os.path.join(MEDIA_DIR, folder), ignore_errors=True)
568
+ if os.path.exists(os.path.join(MEDIA_DIR, "last_run.txt")):
569
+ os.remove(os.path.join(MEDIA_DIR, "last_run.txt"))
570
+ update_media_tabs()
571
+
572
+ def restore_previous_session():
573
+ try:
574
+ state_file = os.path.join(MEDIA_DIR, "last_run.txt")
575
+ if os.path.exists(state_file):
576
+ with open(state_file) as f:
577
+ zip_path = f.read().strip()
578
+ if os.path.exists(zip_path):
579
+ output_dir = os.path.join("./", "ash_output")
580
+ os.makedirs(output_dir, exist_ok=True)
581
+ animator_obj["3d"] = [xr.open_dataset(fp).load() for fp in sorted(glob.glob(os.path.join(output_dir, "3D", "*.nc")))]
582
+ animator_obj["2d"] = [xr.open_dataset(fp).load() for fp in sorted(glob.glob(os.path.join(output_dir, "horizontal", "*.nc")))]
583
+ status.object = f"🔁 Restored previous session from {os.path.basename(zip_path)}"
584
+ update_media_tabs()
585
+ except Exception as e:
586
+ logging.exception("Error restoring previous session")
587
+ status.object = f"⚠️ Could not restore previous session: {e}"
588
+
589
+ process_button.on_click(process_zip)
590
+ reset_button.on_click(reset_app)
591
+
592
+ def build_animator_3d():
593
+ ds = animator_obj["3d"]
594
+ attrs = ds[0].attrs
595
+ lons, lats, grid = create_grid(attrs)
596
+ return SimpleNamespace(datasets=ds, levels=ds[0].altitude.values, lons=lons, lats=lats,
597
+ lon_grid=grid[0], lat_grid=grid[1])
598
+
599
+ def build_animator_2d():
600
+ ds = animator_obj["2d"]
601
+ lat_grid, lon_grid = xr.broadcast(ds[0]["latitude"], ds[0]["longitude"])
602
+ return SimpleNamespace(datasets=ds, lats=ds[0]["latitude"].values,
603
+ lons=ds[0]["longitude"].values,
604
+ lat_grid=lat_grid.values, lon_grid=lon_grid.values)
605
+
606
+ def plot_z_level():
607
+ try:
608
+ animator = build_animator_3d()
609
+ out = os.path.join(MEDIA_DIR, "3D")
610
+ os.makedirs(out, exist_ok=True)
611
+ Plot_3DField_Data(animator, out, cmap_select_3d.value, threshold_slider_3d.value,
612
+ zoom_slider_3d.value, fps_slider_3d.value).plot_single_z_level(
613
+ Altitude_slider.value, f"ash_altitude{Altitude_slider.value}km_runTimes.gif")
614
+ update_media_tabs()
615
+ status.object = "✅ Z-Level animation created."
616
+ except Exception as e:
617
+ logging.exception("Error in plot_z_level")
618
+ status.object = f"❌ Error in Z-Level animation: {e}"
619
+
620
+ def plot_vertical_profile():
621
+ try:
622
+ animator = build_animator_3d()
623
+ out = os.path.join(MEDIA_DIR, "3D")
624
+ os.makedirs(out, exist_ok=True)
625
+ Plot_3DField_Data(animator, out, cmap_select_3d.value, fps_slider_3d.value,
626
+ threshold_slider_3d.value, zoom_level=zoom_slider_3d.value).plot_vertical_profile_at_time(
627
+ Altitude_slider.value - 1, f"T{Altitude_slider.value - 1}_profile.gif")
628
+ update_media_tabs()
629
+ status.object = "✅ Vertical profile animation created."
630
+ except Exception as e:
631
+ logging.exception("Error in plot_vertical_profile")
632
+ status.object = f"❌ Error in vertical profile animation: {e}"
633
+
634
+ def animate_all_altitude_profiles():
635
+ try:
636
+ animator = build_animator_3d()
637
+ out = os.path.join(MEDIA_DIR, "3D")
638
+ Plot_3DField_Data(animator, out, cmap_select_3d.value, threshold_slider_3d.value,
639
+ zoom_slider_3d.value).animate_all_altitude_profiles()
640
+ update_media_tabs()
641
+ status.object = "✅ All altitude profile animations created."
642
+ except Exception as e:
643
+ logging.exception("Error in animate_all_altitude_profiles")
644
+ status.object = f"❌ Error animating all altitude profiles: {e}"
645
+
646
+ def export_jpg_frames():
647
+ try:
648
+ animator = build_animator_3d()
649
+ out = os.path.join(MEDIA_DIR, "3D")
650
+ Plot_3DField_Data(animator, out, cmap_select_3d.value, threshold_slider_3d.value,
651
+ zoom_slider_3d.value).export_frames_as_jpgs(include_metadata=True)
652
+ update_media_tabs()
653
+ status.object = "✅ JPG frames exported."
654
+ except Exception as e:
655
+ logging.exception("Error exporting JPG frames")
656
+ status.object = f"❌ Error exporting JPG frames: {e}"
657
+
658
+ def plot_2d_field(field):
659
+ try:
660
+ animator = build_animator_2d()
661
+ out = os.path.join(MEDIA_DIR, "2D")
662
+ Plot_Horizontal_Data(animator, out, cmap_select_2d.value, fps_slider_2d.value,
663
+ include_metadata=True, threshold=threshold_slider_2d.value,
664
+ zoom_width_deg=6.0, zoom_height_deg=6.0,
665
+ zoom_level=zoom_slider_2d.value,
666
+ static_frame_export=True).plot_single_field_over_time(field, f"{field}.gif")
667
+ update_media_tabs()
668
+ status.object = f"✅ 2D field `{field}` animation created."
669
+ except Exception as e:
670
+ logging.exception(f"Error in plot_2d_field: {field}")
671
+ status.object = f"❌ Error in 2D field `{field}` animation: {e}"
672
+
673
+ def human_readable_size(size):
674
+ for unit in ['B', 'KB', 'MB', 'GB']:
675
+ if size < 1024: return f"{size:.1f} {unit}"
676
+ size /= 1024
677
+ return f"{size:.1f} TB"
678
+
679
+ # def generate_output_gallery(base_folder):
680
+ # grouped = defaultdict(lambda: defaultdict(list))
681
+ # for root, _, files in os.walk(os.path.join(MEDIA_DIR, base_folder)):
682
+ # for file in files:
683
+ # ext = os.path.splitext(file)[1].lower()
684
+ # subfolder = os.path.relpath(root, MEDIA_DIR)
685
+ # grouped[subfolder][ext].append(os.path.join(root, file))
686
+
687
+ # folder_panels = []
688
+ # for subfolder, ext_files in sorted(grouped.items()):
689
+ # section = []
690
+ # for ext, paths in sorted(ext_files.items()):
691
+ # previews = []
692
+ # for path in sorted(paths, key=os.path.getmtime, reverse=True):
693
+ # size = human_readable_size(os.path.getsize(path))
694
+ # mod = datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d %H:%M")
695
+ # title = f"**{os.path.basename(path)}**\n_{size}, {mod}_"
696
+ # download = pn.widgets.FileDownload(label="⬇", file=path, filename=os.path.basename(path), width=60)
697
+ # if ext in [".gif", ".png", ".jpg", ".jpeg"]:
698
+ # preview = pn.pane.Image(path, width=320)
699
+ # else:
700
+ # with open(path, "r", errors="ignore") as f:
701
+ # content = f.read(2048)
702
+ # preview = pn.pane.PreText(content, width=320)
703
+ # card = pn.Card(pn.pane.Markdown(title), preview, pn.Row(download), width=360)
704
+ # previews.append(card)
705
+ # section.append(pn.Column(f"### {ext.upper()}", pn.GridBox(*previews, ncols=2)))
706
+ # folder_section = pn.Card(f"📁 {subfolder}", *section, collapsible=True, width_policy="max")
707
+ # folder_panels.append(folder_section)
708
+
709
+ # return pn.Column(*folder_panels, height=600, scroll=True, sizing_mode='stretch_width', styles={'overflow': 'auto'})
710
+
711
+
712
+
713
+ def generate_output_gallery(base_folder):
714
+ preview_container = pn.Column(width=640, height=550)
715
+ preview_container.append(pn.pane.Markdown("👈 Click a thumbnail to preview"))
716
+ folder_cards = []
717
+
718
+ def make_preview(file_path):
719
+ ext = os.path.splitext(file_path)[1].lower()
720
+ title = pn.pane.Markdown(f"### {os.path.basename(file_path)}", width=640)
721
+ download_button = pn.widgets.FileDownload(file=file_path, filename=os.path.basename(file_path),
722
+ label="⬇ Download", button_type="success", width=150)
723
+
724
+ if ext in [".gif", ".png", ".jpg", ".jpeg"]:
725
+ content = pn.pane.Image(file_path, width=640, height=450, sizing_mode="fixed")
726
+ else:
727
+ try:
728
+ with open(file_path, 'r', errors="ignore") as f:
729
+ text = f.read(2048)
730
+ content = pn.pane.PreText(text, width=640, height=450)
731
+ except:
732
+ content = pn.pane.Markdown("*Unable to preview this file.*")
733
+
734
+ return pn.Column(title, content, download_button)
735
+
736
+ grouped = defaultdict(list)
737
+ for root, _, files in os.walk(os.path.join(MEDIA_DIR, base_folder)):
738
+ for file in sorted(files):
739
+ full_path = os.path.join(root, file)
740
+ if not os.path.exists(full_path):
741
+ continue
742
+ rel_folder = os.path.relpath(root, os.path.join(MEDIA_DIR, base_folder))
743
+ grouped[rel_folder].append(full_path)
744
+
745
+ for folder, file_paths in sorted(grouped.items()):
746
+ thumbnails = []
747
+ for full_path in file_paths:
748
+ filename = os.path.basename(full_path)
749
+ ext = os.path.splitext(full_path)[1].lower()
750
+
751
+ if ext in [".gif", ".png", ".jpg", ".jpeg"]:
752
+ img = pn.pane.Image(full_path, width=140, height=100)
753
+ else:
754
+ img = pn.pane.Markdown("📄", width=140, height=100)
755
+
756
+ view_button = pn.widgets.Button(name="👁", width=40, height=30, button_type="primary")
757
+
758
+ def click_handler(path=full_path):
759
+ def inner_click(event):
760
+ preview_container[:] = [make_preview(path)]
761
+ return inner_click
762
+
763
+ view_button.on_click(click_handler())
764
+
765
+ overlay = pn.Column(pn.Row(pn.Spacer(width=90), view_button), img, width=160)
766
+ label_md = pn.pane.Markdown(f"**{filename}**", width=140, height=35)
767
+ thumb_card = pn.Column(overlay, label_md, width=160)
768
+ thumbnails.append(thumb_card)
769
+
770
+ folder_card = pn.Card(pn.GridBox(*thumbnails, ncols=2), title=f"📁 {folder}", width=400, collapsible=True)
771
+ folder_cards.append(folder_card)
772
+
773
+ folder_scroll = pn.Column(*folder_cards, scroll=True, height=600, width=420)
774
+ return pn.Row(preview_container, pn.Spacer(width=20), folder_scroll)
775
+
776
+ def update_media_tabs():
777
+ media_tab_2d.objects[:] = [generate_output_gallery("2D")]
778
+ media_tab_3d.objects[:] = [generate_output_gallery("3D")]
779
+
780
+ media_tab_2d = pn.Column(generate_output_gallery("2D"))
781
+ media_tab_3d = pn.Column(generate_output_gallery("3D"))
782
+
783
+ media_tab = pn.Tabs(
784
+ ("🖼 2D Output Gallery", media_tab_2d),
785
+ ("🖼 3D Output Gallery", media_tab_3d),
786
+ dynamic=True
787
+ )
788
+
789
  tab3d = pn.Column(
790
  threshold_slider_3d, zoom_slider_3d, fps_slider_3d, Altitude_slider, cmap_select_3d,
791
  pn.widgets.Button(name="🎞 Generate animation at selected altitude level", button_type="primary", on_click=lambda e: tab3d.append(plot_z_level())),
 
801
  pn.widgets.Button(name="💧 Animate Wet Deposition Rate", button_type="primary", on_click=lambda e: tab2d.append(plot_2d_field("wet_deposition_rate"))),
802
  )
803
 
804
+ help_tab = pn.Column(pn.pane.Markdown("""
805
+ ## ❓ How to Use the NAME Ash Visualizer
806
+
807
+ This dashboard allows users to upload and visualize outputs from the NAME ash dispersion model.
808
+
809
+ ### 🧭 Workflow
810
+ 1. **Upload ZIP** containing NetCDF files from the NAME model.
811
+ 2. Use **3D and 2D tabs** to configure and generate animations.
812
+ 3. Use **Media Viewer** to preview and download results.
813
+
814
+ ### 🧳 ZIP Structure
815
+ ```
816
+ ## 🗂 How Uploaded ZIP is Processed
817
+
818
+ ```text
819
+ ┌────────────────────────────────────────────┐
820
+ │ Uploaded ZIP (.zip) │
821
+ │ (e.g. Taal_273070_20200112_scenario_*.zip)│
822
+ └────────────────────────────────────────────┘
823
+
824
+
825
+ ┌───────────────────────────────┐
826
+ │ Contains: raw .txt outputs │
827
+ │ - AQOutput_3DField_*.txt │
828
+ │ - AQOutput_horizontal_*.txt │
829
+ └───────────────────────────────┘
830
+
831
+
832
+ ┌────────────────────────────────────────┐
833
+ │ NAMEDataProcessor.batch_process_zip()│
834
+ └──────────────────────────��─────────────┘
835
+
836
+
837
+ ┌─────────────────────────────┐
838
+ │ Converts to NetCDF files │
839
+ │ - ash_output/3D/*.nc │
840
+ │ - ash_output/horizontal/*.nc │
841
+ └─────────────────────────────┘
842
+
843
+
844
+ ┌─────────────────────────────────────┐
845
+ │ View & animate in 3D/2D tabs │
846
+ │ Download results in Media Viewer │
847
+ └─────────────────────────────────────┘
848
+
849
+ ```
850
+
851
+ ### 📢 Tips
852
+ - Reset the app with 🔄 if needed.
853
+ - View logs if an error occurs.
854
+ - Outputs are temporary per session.
855
+ """, sizing_mode="stretch_width", width=800))
856
+
857
  tabs = pn.Tabs(
858
  ("🧱 3D Field", tab3d),
859
  ("🌍 2D Field", tab2d),
860
+ ("📁 Media Viewer", media_tab),
861
+ ("❓ Help", help_tab)
862
  )
863
 
864
+ sidebar = pn.Column(
865
+ pn.pane.Markdown("## 🌋 NAME Ash Visualizer", sizing_mode="stretch_width"),
866
+ pn.Card(pn.Column(file_input, process_button, reset_button, sizing_mode="stretch_width"),
867
+ title="📂 File Upload & Processing", collapsible=True, sizing_mode="stretch_width"),
868
+ pn.Card(pn.Column(download_button, log_link, sizing_mode="stretch_width"),
869
+ title="📁 Downloads & Logs", collapsible=True, sizing_mode="stretch_width"),
870
+ pn.Card(status, title="📢 Status", collapsible=True, sizing_mode="stretch_width"),
871
+ sizing_mode="stretch_width", width=300
872
+ )
873
 
874
  restore_previous_session()
875
 
 
877
  title="NAME Visualizer Dashboard",
878
  sidebar=sidebar,
879
  main=[tabs],
880
+ ).servable()
881
+ '''
ash_animator/__pycache__/animation_single.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/animation_single.cpython-312.pyc and b/ash_animator/__pycache__/animation_single.cpython-312.pyc differ
 
ash_animator/__pycache__/animation_vertical.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/animation_vertical.cpython-312.pyc and b/ash_animator/__pycache__/animation_vertical.cpython-312.pyc differ
 
ash_animator/__pycache__/basemaps.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/basemaps.cpython-312.pyc and b/ash_animator/__pycache__/basemaps.cpython-312.pyc differ
 
ash_animator/__pycache__/converter.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/converter.cpython-312.pyc and b/ash_animator/__pycache__/converter.cpython-312.pyc differ
 
ash_animator/__pycache__/export.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/export.cpython-312.pyc and b/ash_animator/__pycache__/export.cpython-312.pyc differ
 
ash_animator/__pycache__/interpolation.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/interpolation.cpython-312.pyc and b/ash_animator/__pycache__/interpolation.cpython-312.pyc differ
 
ash_animator/__pycache__/plot_3dfield_data.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/plot_3dfield_data.cpython-312.pyc and b/ash_animator/__pycache__/plot_3dfield_data.cpython-312.pyc differ
 
ash_animator/__pycache__/plot_horizontal_data.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/plot_horizontal_data.cpython-312.pyc and b/ash_animator/__pycache__/plot_horizontal_data.cpython-312.pyc differ
 
ash_animator/__pycache__/utils.cpython-312.pyc CHANGED
Binary files a/ash_animator/__pycache__/utils.cpython-312.pyc and b/ash_animator/__pycache__/utils.cpython-312.pyc differ
 
ash_animator/basemaps.py CHANGED
@@ -8,33 +8,52 @@ import cartopy.feature as cfeature
8
  from PIL import Image
9
  import matplotlib.pyplot as plt
10
 
11
- def get_safe_dir(name):
12
- """
13
- Returns the first writable directory from a list of fallback options.
14
- Returns None if all fail.
15
- """
16
- fallback_paths = [
17
- os.path.join("/code", name),
18
- os.path.join(tempfile.gettempdir(), name),
19
- os.path.expanduser(f"~/.{name}")
20
- ]
21
- for path in fallback_paths:
22
- try:
23
- os.makedirs(path, exist_ok=True)
24
- return path
25
- except PermissionError:
26
- continue
27
- return None
28
-
29
- # Define and validate cache directories
30
- CTX_TILE_CACHE_DIR = get_safe_dir("contextily_cache")
31
- BASEMAP_TILE_CACHE_DIR = get_safe_dir("basemap_cache")
32
-
33
- # Set environment variable only if safe
34
- if CTX_TILE_CACHE_DIR:
35
- os.environ["XDG_CACHE_HOME"] = CTX_TILE_CACHE_DIR
36
 
37
  def draw_etopo_basemap(ax, mode="basemap", zoom=11):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  try:
39
  if mode == "stock":
40
  ax.stock_img()
@@ -53,32 +72,27 @@ def draw_etopo_basemap(ax, mode="basemap", zoom=11):
53
  extent = ax.get_extent(crs=ccrs.PlateCarree())
54
  extent_str = f"{extent[0]:.4f}_{extent[1]:.4f}_{extent[2]:.4f}_{extent[3]:.4f}"
55
  cache_key = hashlib.md5(extent_str.encode()).hexdigest()
56
- cache_file = os.path.join(BASEMAP_TILE_CACHE_DIR, f"{cache_key}_highres.png") if BASEMAP_TILE_CACHE_DIR else None
57
 
58
- if cache_file and os.path.exists(cache_file):
59
  img = Image.open(cache_file)
60
  ax.imshow(img, extent=extent, transform=ccrs.PlateCarree())
61
  else:
62
  temp_fig, temp_ax = plt.subplots(figsize=(12, 9),
63
  subplot_kw={'projection': ccrs.PlateCarree()})
64
  temp_ax.set_extent(extent, crs=ccrs.PlateCarree())
 
65
  m = Basemap(projection='cyl',
66
  llcrnrlon=extent[0], urcrnrlon=extent[1],
67
  llcrnrlat=extent[2], urcrnrlat=extent[3],
68
  resolution='f', ax=temp_ax)
69
  m.shadedrelief()
70
 
71
- if cache_file:
72
- try:
73
- temp_fig.savefig(cache_file, dpi=300, bbox_inches='tight', pad_inches=0)
74
- except Exception as e:
75
- print(f"[Cache Save Failed]: {e}")
76
  plt.close(temp_fig)
77
 
78
- # Display image only if it was saved
79
- if cache_file and os.path.exists(cache_file):
80
- img = Image.open(cache_file)
81
- ax.imshow(img, extent=extent, transform=ccrs.PlateCarree())
82
 
83
  else:
84
  raise ValueError(f"Unsupported basemap mode: {mode}")
 
8
  from PIL import Image
9
  import matplotlib.pyplot as plt
10
 
11
+ # Determine platform and fallback cache path
12
+ def get_cache_dir(app_name):
13
+ if os.name == 'nt':
14
+ return os.path.join(os.getenv('LOCALAPPDATA', tempfile.gettempdir()), f"{app_name}_cache")
15
+ elif os.name == 'posix':
16
+ home_dir = os.path.expanduser("~")
17
+ if os.path.isdir(home_dir) and os.access(home_dir, os.W_OK):
18
+ return os.path.join(home_dir, f".{app_name}_cache")
19
+ else:
20
+ return os.path.join(tempfile.gettempdir(), f"{app_name}_cache")
21
+ else:
22
+ return os.path.join(tempfile.gettempdir(), f"{app_name}_cache")
23
+
24
+ # Define cache directories
25
+ CTX_TILE_CACHE_DIR = get_cache_dir("contextily")
26
+ BASEMAP_TILE_CACHE_DIR = get_cache_dir("basemap")
27
+
28
+ os.environ["XDG_CACHE_HOME"] = CTX_TILE_CACHE_DIR
29
+ os.makedirs(CTX_TILE_CACHE_DIR, exist_ok=True)
30
+ os.makedirs(BASEMAP_TILE_CACHE_DIR, exist_ok=True)
 
 
 
 
 
31
 
32
  def draw_etopo_basemap(ax, mode="basemap", zoom=11):
33
+ """
34
+ Draws a high-resolution basemap background on the provided Cartopy GeoAxes.
35
+
36
+ Parameters
37
+ ----------
38
+ ax : matplotlib.axes._subplots.AxesSubplot
39
+ The matplotlib Axes object (with Cartopy projection) to draw the map background on.
40
+
41
+ mode : str, optional
42
+ The basemap mode to use:
43
+ - "stock": Default stock image from Cartopy.
44
+ - "contextily": Web tile background (CartoDB Voyager), with caching.
45
+ - "basemap": High-resolution shaded relief using Basemap, with caching.
46
+ Default is "basemap".
47
+
48
+ zoom : int, optional
49
+ Tile zoom level (only for "contextily"). Higher = more detail. Default is 7.
50
+
51
+ Notes
52
+ -----
53
+ - Uses high resolution for Basemap (resolution='h') and saves figure at 300 DPI.
54
+ - Cached images are reused using extent-based hashing to avoid re-rendering.
55
+ - Basemap is deprecated; Cartopy with web tiles is recommended for new projects.
56
+ """
57
  try:
58
  if mode == "stock":
59
  ax.stock_img()
 
72
  extent = ax.get_extent(crs=ccrs.PlateCarree())
73
  extent_str = f"{extent[0]:.4f}_{extent[1]:.4f}_{extent[2]:.4f}_{extent[3]:.4f}"
74
  cache_key = hashlib.md5(extent_str.encode()).hexdigest()
75
+ cache_file = os.path.join(BASEMAP_TILE_CACHE_DIR, f"{cache_key}_highres.png")
76
 
77
+ if os.path.exists(cache_file):
78
  img = Image.open(cache_file)
79
  ax.imshow(img, extent=extent, transform=ccrs.PlateCarree())
80
  else:
81
  temp_fig, temp_ax = plt.subplots(figsize=(12, 9),
82
  subplot_kw={'projection': ccrs.PlateCarree()})
83
  temp_ax.set_extent(extent, crs=ccrs.PlateCarree())
84
+
85
  m = Basemap(projection='cyl',
86
  llcrnrlon=extent[0], urcrnrlon=extent[1],
87
  llcrnrlat=extent[2], urcrnrlat=extent[3],
88
  resolution='f', ax=temp_ax)
89
  m.shadedrelief()
90
 
91
+ temp_fig.savefig(cache_file, dpi=300, bbox_inches='tight', pad_inches=0)
 
 
 
 
92
  plt.close(temp_fig)
93
 
94
+ img = Image.open(cache_file)
95
+ ax.imshow(img, extent=extent, transform=ccrs.PlateCarree())
 
 
96
 
97
  else:
98
  raise ValueError(f"Unsupported basemap mode: {mode}")
ash_animator/converter.py CHANGED
@@ -1,148 +1,4 @@
1
- # # Full updated and corrected version of NAMEDataConverter with sanitized metadata keys
2
-
3
- # import os
4
- # import re
5
- # import zipfile
6
- # import shutil
7
- # import numpy as np
8
- # import xarray as xr
9
- # import matplotlib.pyplot as plt
10
- # import matplotlib.animation as animation
11
- # from typing import List, Tuple
12
-
13
- # class NAMEDataConverter:
14
- # def __init__(self, output_dir: str):
15
- # self.output_dir = output_dir
16
- # os.makedirs(self.output_dir, exist_ok=True)
17
-
18
- # def _sanitize_key(self, key: str) -> str:
19
- # # Replace non-alphanumeric characters with underscores, and ensure it starts with a letter
20
- # key = re.sub(r'\W+', '_', key)
21
- # if not key[0].isalpha():
22
- # key = f"attr_{key}"
23
- # return key
24
-
25
- # def _parse_metadata(self, lines: List[str]) -> dict:
26
- # metadata = {}
27
- # for line in lines:
28
- # if ":" in line:
29
- # key, value = line.split(":", 1)
30
- # clean_key = self._sanitize_key(key.strip().lower())
31
- # metadata[clean_key] = value.strip()
32
-
33
- # try:
34
- # metadata.update({
35
- # "x_origin": float(metadata["x_grid_origin"]),
36
- # "y_origin": float(metadata["y_grid_origin"]),
37
- # "x_size": int(metadata["x_grid_size"]),
38
- # "y_size": int(metadata["y_grid_size"]),
39
- # "x_res": float(metadata["x_grid_resolution"]),
40
- # "y_res": float(metadata["y_grid_resolution"]),
41
- # "prelim_cols": int(metadata["number_of_preliminary_cols"]),
42
- # "n_fields": int(metadata["number_of_field_cols"]),
43
- # })
44
- # except KeyError as e:
45
- # raise ValueError(f"Missing required metadata field: {e}")
46
- # except ValueError as e:
47
- # raise ValueError(f"Invalid value in metadata: {e}")
48
-
49
- # if metadata["x_res"] == 0 or metadata["y_res"] == 0:
50
- # raise ZeroDivisionError("Grid resolution cannot be zero.")
51
-
52
- # return metadata
53
-
54
- # def _get_data_lines(self, lines: List[str]) -> List[str]:
55
- # idx = next(i for i, l in enumerate(lines) if l.strip() == "Fields:")
56
- # return lines[idx + 1:]
57
-
58
- # def convert_3d_group(self, group: List[Tuple[int, str]], output_filename: str) -> str:
59
- # first_file_path = group[0][1]
60
- # with open(first_file_path, 'r') as f:
61
- # lines = f.readlines()
62
- # meta = self._parse_metadata(lines)
63
-
64
- # lons = np.round(np.arange(meta["x_origin"], meta["x_origin"] + meta["x_size"] * meta["x_res"], meta["x_res"]), 6)
65
- # lats = np.round(np.arange(meta["y_origin"], meta["y_origin"] + meta["y_size"] * meta["y_res"], meta["y_res"]), 6)
66
-
67
- # z_levels = []
68
- # z_coords = []
69
-
70
- # for z_idx, filepath in group:
71
- # with open(filepath, 'r') as f:
72
- # lines = f.readlines()
73
- # data_lines = self._get_data_lines(lines)
74
- # grid = np.zeros((meta["y_size"], meta["x_size"]), dtype=np.float32)
75
-
76
- # for line in data_lines:
77
- # parts = [p.strip().strip(',') for p in line.strip().split(',') if p.strip()]
78
- # if len(parts) >= 5 and parts[0].isdigit() and parts[1].isdigit():
79
- # try:
80
- # x = int(parts[0]) - 1
81
- # y = int(parts[1]) - 1
82
- # val = float(parts[4])
83
- # if 0 <= x < meta["x_size"] and 0 <= y < meta["y_size"]:
84
- # grid[y, x] = val
85
- # except Exception:
86
- # continue
87
- # z_levels.append(grid)
88
- # z_coords.append(z_idx)
89
-
90
- # z_cube = np.stack(z_levels, axis=0)
91
- # ds = xr.Dataset(
92
- # {
93
- # "ash_concentration": (['altitude', 'latitude', 'longitude'], z_cube)
94
- # },
95
- # coords={
96
- # "altitude": np.array(z_coords, dtype=np.float32),
97
- # "latitude": lats,
98
- # "longitude": lons
99
- # },
100
- # attrs={
101
- # "title": "Volcanic Ash Concentration",
102
- # "source": "NAME model output processed to NetCDF",
103
- # **{k: str(v) for k, v in meta.items()} # Ensure all attrs are strings
104
- # }
105
- # )
106
- # ds["ash_concentration"].attrs.update({
107
- # "units": "g/m^3",
108
- # "long_name": "Volcanic ash concentration"
109
- # })
110
- # ds["altitude"].attrs["units"] = "kilometers above sea level"
111
- # ds["latitude"].attrs["units"] = "degrees_north"
112
- # ds["longitude"].attrs["units"] = "degrees_east"
113
-
114
- # out_path = os.path.join(self.output_dir, output_filename)
115
- # ds.to_netcdf(out_path)
116
- # return out_path
117
-
118
- # def batch_process_zip(self, zip_path: str) -> List[str]:
119
- # extract_dir = os.path.join(self.output_dir, "unzipped")
120
- # os.makedirs(extract_dir, exist_ok=True)
121
-
122
- # with zipfile.ZipFile(zip_path, 'r') as zip_ref:
123
- # zip_ref.extractall(extract_dir)
124
-
125
- # txt_files = []
126
- # for root, _, files in os.walk(extract_dir):
127
- # for file in files:
128
- # if file.endswith(".txt"):
129
- # txt_files.append(os.path.join(root, file))
130
-
131
- # pattern = re.compile(r"_T(\d+)_.*_Z(\d+)\.txt$")
132
- # grouped = {}
133
- # for f in txt_files:
134
- # match = pattern.search(f)
135
- # if match:
136
- # t = int(match.group(1))
137
- # z = int(match.group(2))
138
- # grouped.setdefault(t, []).append((z, f))
139
-
140
- # nc_files = []
141
- # for t_key in sorted(grouped):
142
- # group = sorted(grouped[t_key])
143
- # out_nc = self.convert_3d_group(group, f"T{t_key}.nc")
144
- # nc_files.append(out_nc)
145
- # return nc_files
146
 
147
  # Re-defining the integrated class first
148
  import os
@@ -154,8 +10,17 @@ from typing import List, Tuple
154
  import shutil
155
 
156
 
 
 
157
  class NAMEDataProcessor:
158
- def __init__(self, output_root: str):
 
 
 
 
 
 
 
159
  self.output_root = output_root
160
  self.output_3d = os.path.join(self.output_root, "3D")
161
  self.output_horizontal = os.path.join(self.output_root, "horizontal")
@@ -332,7 +197,7 @@ class NAMEDataProcessor:
332
 
333
 
334
  def batch_process_zip(self, zip_path: str) -> List[str]:
335
- extract_dir = os.path.abspath("unzipped")
336
 
337
  os.makedirs(extract_dir, exist_ok=True)
338
 
 
1
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  # Re-defining the integrated class first
4
  import os
 
10
  import shutil
11
 
12
 
13
+ import tempfile # Added for safe temp directory usage
14
+
15
  class NAMEDataProcessor:
16
+ def __init__(self, output_root: str = None):
17
+ if output_root is None:
18
+ output_root = os.path.join(tempfile.gettempdir(), "name_outputs")
19
+ self.output_root = output_root
20
+ self.output_3d = os.path.join(self.output_root, "3D")
21
+ self.output_horizontal = os.path.join(self.output_root, "horizontal")
22
+ os.makedirs(self.output_3d, exist_ok=True)
23
+ os.makedirs(self.output_horizontal, exist_ok=True)
24
  self.output_root = output_root
25
  self.output_3d = os.path.join(self.output_root, "3D")
26
  self.output_horizontal = os.path.join(self.output_root, "horizontal")
 
197
 
198
 
199
  def batch_process_zip(self, zip_path: str) -> List[str]:
200
+ extract_dir = os.path.join(tempfile.gettempdir(), "unzipped_name_extract")
201
 
202
  os.makedirs(extract_dir, exist_ok=True)
203
 
ash_animator/plot_3dfield_data.py CHANGED
@@ -11,6 +11,7 @@ from .interpolation import interpolate_grid
11
  from .basemaps import draw_etopo_basemap
12
  import imageio.v2 as imageio
13
  import shutil
 
14
 
15
  class Plot_3DField_Data:
16
 
@@ -70,7 +71,13 @@ class Plot_3DField_Data:
70
  include_metadata=True, threshold=0.1,
71
  zoom_width_deg=6.0, zoom_height_deg=6.0, zoom_level=7, basemap_type="basemap"):
72
  self.animator = animator
73
- self.output_dir = os.path.abspath(os.path.join(os.getcwd(), output_dir))
 
 
 
 
 
 
74
  os.makedirs(self.output_dir, exist_ok=True)
75
  self.cmap = cmap
76
  self.fps = fps
 
11
  from .basemaps import draw_etopo_basemap
12
  import imageio.v2 as imageio
13
  import shutil
14
+ import tempfile
15
 
16
  class Plot_3DField_Data:
17
 
 
71
  include_metadata=True, threshold=0.1,
72
  zoom_width_deg=6.0, zoom_height_deg=6.0, zoom_level=7, basemap_type="basemap"):
73
  self.animator = animator
74
+
75
+ self.output_dir = os.path.abspath(
76
+ os.path.join(
77
+ os.environ.get("NAME_OUTPUT_DIR", tempfile.gettempdir()),
78
+ output_dir
79
+ )
80
+ )
81
  os.makedirs(self.output_dir, exist_ok=True)
82
  self.cmap = cmap
83
  self.fps = fps
ash_animator/plot_horizontal_data.py CHANGED
@@ -9,13 +9,20 @@ import cartopy.io.shapereader as shpreader
9
  from adjustText import adjust_text
10
  from ash_animator.interpolation import interpolate_grid
11
  from ash_animator.basemaps import draw_etopo_basemap
 
12
 
13
  class Plot_Horizontal_Data:
14
  def __init__(self, animator, output_dir="plots", cmap="rainbow", fps=2,
15
  include_metadata=True, threshold=0.1,
16
  zoom_width_deg=6.0, zoom_height_deg=6.0, zoom_level=7, static_frame_export=False):
17
  self.animator = animator
18
- self.output_dir = os.path.abspath(os.path.join(os.getcwd(), output_dir))
 
 
 
 
 
 
19
  os.makedirs(self.output_dir, exist_ok=True)
20
  self.cmap = cmap
21
  self.fps = fps
@@ -174,7 +181,7 @@ class Plot_Horizontal_Data:
174
 
175
  # Inside update() function:
176
  if not hasattr(update, "colorbar"):
177
- unit_label = f"{field}:({self.animator.datasets[0][field].attrs.get('units', field)})" #self.animator.datasets[0][field].attrs.get("units", field)
178
  update.colorbar = fig.colorbar(c, ax=[ax1, ax2], orientation='vertical', label=unit_label)
179
  formatter = mticker.FuncFormatter(lambda x, _: f'{x:.2g}')
180
  update.colorbar.ax.yaxis.set_major_formatter(formatter)
 
9
  from adjustText import adjust_text
10
  from ash_animator.interpolation import interpolate_grid
11
  from ash_animator.basemaps import draw_etopo_basemap
12
+ import tempfile
13
 
14
  class Plot_Horizontal_Data:
15
  def __init__(self, animator, output_dir="plots", cmap="rainbow", fps=2,
16
  include_metadata=True, threshold=0.1,
17
  zoom_width_deg=6.0, zoom_height_deg=6.0, zoom_level=7, static_frame_export=False):
18
  self.animator = animator
19
+
20
+ self.output_dir = os.path.abspath(
21
+ os.path.join(
22
+ os.environ.get("NAME_OUTPUT_DIR", tempfile.gettempdir()),
23
+ output_dir
24
+ )
25
+ )
26
  os.makedirs(self.output_dir, exist_ok=True)
27
  self.cmap = cmap
28
  self.fps = fps
 
181
 
182
  # Inside update() function:
183
  if not hasattr(update, "colorbar"):
184
+ unit_label = f"{field}:({self.animator.datasets[0][field].attrs.get("units", field)})" #self.animator.datasets[0][field].attrs.get("units", field)
185
  update.colorbar = fig.colorbar(c, ax=[ax1, ax2], orientation='vertical', label=unit_label)
186
  formatter = mticker.FuncFormatter(lambda x, _: f'{x:.2g}')
187
  update.colorbar.ax.yaxis.set_major_formatter(formatter)