nakas commited on
Commit
2b1f0e8
·
1 Parent(s): 7490235

feat(compare): add non-animated Leaflet static overlay side-by-side with Plotly; feat(export): KMZ overlay export (gx:LatLonQuad) with transparent PNG

Browse files
Files changed (1) hide show
  1. app.py +307 -5
app.py CHANGED
@@ -469,6 +469,198 @@ def add_radar_image_layer(fig: go.Figure, lat2d: np.ndarray, lon2d: np.ndarray,
469
  print(f"Image layer error: {e}")
470
  return False
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  def _locate_or_download_grib(forecast_hour: int):
473
  """Return local GRIB2 path for HRRR REFC at fxx, downloading if needed."""
474
  if not HERBIE_AVAILABLE:
@@ -633,6 +825,85 @@ def export_radar_grib(forecast_hour: int, min_dbz: float):
633
  except Exception as e:
634
  return None, f"Export error: {e}"
635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  def download_raw_grib(forecast_hour: int):
637
  """Return a copy-path under ./exports for the raw HRRR GRIB2 file used for REFC at the given forecast hour."""
638
  try:
@@ -1276,6 +1547,7 @@ def update_display(location, forecast_hour, parameter, show_radar_overlay, detai
1276
  # Optional animation and Leaflet overlay
1277
  gif_path = None
1278
  leaflet_html = ""
 
1279
  if animate_forecast:
1280
  try:
1281
  gif_path, _ = generate_radar_animation_gif(detail_level=int(detail_level), min_dbz=float(min_dbz))
@@ -1294,7 +1566,13 @@ def update_display(location, forecast_hour, parameter, show_radar_overlay, detai
1294
  except Exception as e:
1295
  leaflet_html = f"<div style='padding:8px;color:#900'>Leaflet overlay build failed: {str(e)}</div>"
1296
 
1297
- return status, weather_map, gif_path, leaflet_html
 
 
 
 
 
 
1298
 
1299
  except Exception as e:
1300
  print(f"Update error: {e}")
@@ -1305,7 +1583,7 @@ def update_display(location, forecast_hour, parameter, show_radar_overlay, detai
1305
  error_fig.add_annotation(text=f"Update failed: {str(e)}", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
1306
  error_fig.update_layout(height=300)
1307
 
1308
- return f"## Error\n{str(e)}", error_fig, None, ""
1309
 
1310
  # Stable interface - single map only
1311
  with gr.Blocks(title="HRRR Weather + Radar") as app:
@@ -1379,7 +1657,9 @@ with gr.Blocks(title="HRRR Weather + Radar") as app:
1379
 
1380
  with gr.Column():
1381
  status_text = gr.Markdown("Click button to fetch HRRR weather + radar data")
1382
- weather_map = gr.Plot()
 
 
1383
  animation_view = gr.Image(label="Radar Animation (0–18h)")
1384
  leaflet_overlay = gr.HTML(label="Leaflet Overlay")
1385
  export_file = gr.File(label="GRIB2 Export", visible=True)
@@ -1389,14 +1669,14 @@ with gr.Blocks(title="HRRR Weather + Radar") as app:
1389
  update_btn.click(
1390
  fn=update_display,
1391
  inputs=[location, forecast_hour, parameter, show_radar_overlay, detail_level, min_dbz, animate_forecast],
1392
- outputs=[status_text, weather_map, animation_view, leaflet_overlay]
1393
  )
1394
 
1395
  # Auto-update when toggling radar
1396
  show_radar_overlay.change(
1397
  fn=update_display,
1398
  inputs=[location, forecast_hour, parameter, show_radar_overlay, detail_level, min_dbz, animate_forecast],
1399
- outputs=[status_text, weather_map, animation_view, leaflet_overlay]
1400
  )
1401
 
1402
  # Export GRIB button
@@ -1419,6 +1699,28 @@ with gr.Blocks(title="HRRR Weather + Radar") as app:
1419
  outputs=[export_file]
1420
  )
1421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1422
  def _download_raw_handler(forecast_hour):
1423
  path, msg = download_raw_grib(int(forecast_hour))
1424
  if path:
 
469
  print(f"Image layer error: {e}")
470
  return False
471
 
472
+ def render_radar_png_data_url(z2d: np.ndarray, detail_level: int = 5) -> Optional[str]:
473
+ """Render a single radar frame (z2d) to a transparent PNG data URL."""
474
+ try:
475
+ import io, base64
476
+ import matplotlib
477
+ matplotlib.use('Agg', force=True)
478
+ import matplotlib.pyplot as plt
479
+
480
+ zmask = np.ma.masked_invalid(z2d)
481
+ cmap = build_mpl_colormap(get_radar_colorscale())
482
+ if cmap is None:
483
+ return None
484
+
485
+ ny, nx = z2d.shape
486
+ scale_map = {1: 1.2, 2: 1.6, 3: 2.0, 4: 3.0, 5: 4.0}
487
+ scale = scale_map.get(int(detail_level) if detail_level is not None else 3, 2.0)
488
+ max_pixels = 2_400_000
489
+ width = int(nx * scale)
490
+ height = int(ny * scale)
491
+ if width * height > max_pixels:
492
+ ratio = math.sqrt(max_pixels / (width * height))
493
+ width = max(64, int(width * ratio))
494
+ height = max(64, int(height * ratio))
495
+
496
+ dpi = 100
497
+ fig_img = plt.figure(figsize=(width / dpi, height / dpi), dpi=dpi)
498
+ fig_img.patch.set_alpha(0.0)
499
+ ax = fig_img.add_axes([0, 0, 1, 1])
500
+ ax.patch.set_alpha(0.0)
501
+ ax.imshow(zmask, cmap=cmap, vmin=0, vmax=65, origin='upper', interpolation='bilinear')
502
+ ax.axis('off')
503
+
504
+ buf = io.BytesIO()
505
+ fig_img.savefig(buf, format='png', dpi=dpi, transparent=True)
506
+ plt.close(fig_img)
507
+ img_b64 = base64.b64encode(buf.getvalue()).decode('ascii')
508
+ return f"data:image/png;base64,{img_b64}"
509
+ except Exception as e:
510
+ print(f"render_radar_png_data_url error: {e}")
511
+ return None
512
+
513
+ def build_leaflet_static_overlay_html(grid: Optional[Dict[str, Any]], detail_level: int = 5) -> str:
514
+ """Build a Leaflet HTML with a single transparent PNG overlaid using piecewise projective warping."""
515
+ try:
516
+ if not grid:
517
+ return "<div style='padding:8px;color:#666'>No radar grid available.</div>"
518
+
519
+ lat2d = grid['lat2d']
520
+ lon2d = grid['lon2d']
521
+ z2d = grid['z2d']
522
+ data_url = render_radar_png_data_url(z2d, detail_level)
523
+ if not data_url:
524
+ return "<div style='padding:8px;color:#900'>Failed to render radar image.</div>"
525
+
526
+ min_lat = float(np.nanmin(lat2d))
527
+ max_lat = float(np.nanmax(lat2d))
528
+ min_lon = float(np.nanmin(lon2d))
529
+ max_lon = float(np.nanmax(lon2d))
530
+ c_lat = float(np.nanmean(lat2d))
531
+ c_lon = float(np.nanmean(lon2d))
532
+
533
+ ny, nx = lat2d.shape
534
+ TY = max(6, min(18, ny // 10))
535
+ TX = max(8, min(24, nx // 10))
536
+ yi = np.linspace(0, ny - 1, TY + 1).astype(int)
537
+ xi = np.linspace(0, nx - 1, TX + 1).astype(int)
538
+ tiles = []
539
+ for i in range(TY):
540
+ for j in range(TX):
541
+ i0, i1 = yi[i], yi[i+1]
542
+ j0, j1 = xi[j], xi[j+1]
543
+ tiles.append({
544
+ 'tl': [float(lat2d[i0, j0]), float(lon2d[i0, j0])],
545
+ 'tr': [float(lat2d[i0, j1]), float(lon2d[i0, j1])],
546
+ 'br': [float(lat2d[i1, j1]), float(lon2d[i1, j1])],
547
+ 'bl': [float(lat2d[i1, j0]), float(lon2d[i1, j0])],
548
+ 'ti': int(i), 'tj': int(j)
549
+ })
550
+ tiles_json = json.dumps(tiles)
551
+
552
+ doc = f"""
553
+ <!doctype html>
554
+ <html>
555
+ <head>
556
+ <meta charset=\"utf-8\" />
557
+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
558
+ <link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"/>
559
+ <style>
560
+ html,body,#leaflet-map{{height:100%;margin:0;padding:0}}
561
+ .tile img{{pointer-events:none;}}
562
+ </style>
563
+ </head>
564
+ <body>
565
+ <div id=\"leaflet-map\"></div>
566
+ <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script>
567
+ <script>
568
+ (function() {{
569
+ var map = L.map('leaflet-map', {{center: [{c_lat:.5f}, {c_lon:.5f}], zoom: 5, zoomControl: true}});
570
+ L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
571
+ maxZoom: 18,
572
+ attribution: '&copy; OpenStreetMap contributors'
573
+ }}).addTo(map);
574
+ var bounds = [[{min_lat:.6f}, {min_lon:.6f}], [{max_lat:.6f}, {max_lon:.6f}]];
575
+ map.fitBounds(bounds);
576
+
577
+ var frame = '{data_url}';
578
+ var tiles = {tiles_json};
579
+ var TX = {TX};
580
+ var TY = {TY};
581
+ var overlayPane = map.getPanes().overlayPane;
582
+ var container = document.createElement('div');
583
+ container.style.position = 'absolute';
584
+ overlayPane.appendChild(container);
585
+
586
+ var baseImg = new Image();
587
+ baseImg.onload = function() {{
588
+ var w = baseImg.naturalWidth, h = baseImg.naturalHeight;
589
+ var tw = w / TX, th = h / TY;
590
+ var tilesEls = [];
591
+ for (var k=0; k<tiles.length; k++) {{
592
+ var t = tiles[k];
593
+ var tile = document.createElement('div');
594
+ tile.className = 'tile';
595
+ tile.style.position = 'absolute';
596
+ tile.style.width = tw + 'px';
597
+ tile.style.height = th + 'px';
598
+ tile.style.transformOrigin = '0 0';
599
+ var im = new Image();
600
+ im.src = frame;
601
+ im.style.position = 'absolute';
602
+ im.style.left = (-t.tj * tw) + 'px';
603
+ im.style.top = (-t.ti * th) + 'px';
604
+ im.style.width = w + 'px';
605
+ im.style.height = h + 'px';
606
+ tile.appendChild(im);
607
+ container.appendChild(tile);
608
+ tilesEls.push({{tile: tile, t: t}});
609
+ }}
610
+
611
+ function applyTransforms() {{
612
+ for (var kk=0; kk<tilesEls.length; kk++) {{
613
+ var el = tilesEls[kk];
614
+ var t = el.t;
615
+ var p0 = map.latLngToLayerPoint([t.tl[0], t.tl[1]]);
616
+ var p1 = map.latLngToLayerPoint([t.tr[0], t.tr[1]]);
617
+ var p2 = map.latLngToLayerPoint([t.br[0], t.br[1]]);
618
+ var p3 = map.latLngToLayerPoint([t.bl[0], t.bl[1]]);
619
+ var x0=p0.x, y0=p0.y, x1=p1.x, y1=p1.y, x2=p2.x, y2=p2.y, x3=p3.x, y3=p3.y;
620
+ var dx1 = x1 - x2, dy1 = y1 - y2;
621
+ var dx2 = x3 - x2, dy2 = y3 - y2;
622
+ var dx3 = x0 - x1 + x2 - x3, dy3 = y0 - y1 + y2 - y3;
623
+ var a, b, c, d, e, f, g, h2;
624
+ if (dx3 === 0 && dy3 === 0) {{
625
+ g = 0; h2 = 0;
626
+ a = x1 - x0; b = x3 - x0; c = x0;
627
+ d = y1 - y0; e = y3 - y0; f = y0;
628
+ }} else {{
629
+ var denom = dx1*dy2 - dx2*dy1;
630
+ g = (dx3*dy2 - dx2*dy3)/denom;
631
+ h2 = (dx1*dy3 - dx3*dy1)/denom;
632
+ a = x1 - x0 + g*x1;
633
+ b = x3 - x0 + h2*x3;
634
+ c = x0;
635
+ d = y1 - y0 + g*y1;
636
+ e = y3 - y0 + h2*y3;
637
+ f = y0;
638
+ }}
639
+ a /= tw; b /= th; d /= tw; e /= th; g /= tw; h2 /= th;
640
+ var css = 'matrix3d('+
641
+ a + ',' + d + ',0,' + g + ','+
642
+ b + ',' + e + ',0,' + h2 + ','+
643
+ '0,0,1,0,'+
644
+ c + ',' + f + ',0,1)';
645
+ el.tile.style.transform = css;
646
+ }}
647
+ }}
648
+
649
+ applyTransforms();
650
+ map.on('zoom viewreset move', applyTransforms);
651
+ }};
652
+ baseImg.src = frame;
653
+ }})();
654
+ </script>
655
+ </body>
656
+ </html>
657
+ """
658
+ # Escape for srcdoc
659
+ doc_escaped = doc.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
660
+ return f"<iframe srcdoc=\"{doc_escaped}\" style=\"width:100%;height:520px;border:none;border-radius:8px\"></iframe>"
661
+ except Exception as e:
662
+ return f"<div style='padding:8px;color:#900'>Leaflet static overlay error: {str(e)}</div>"
663
+
664
  def _locate_or_download_grib(forecast_hour: int):
665
  """Return local GRIB2 path for HRRR REFC at fxx, downloading if needed."""
666
  if not HERBIE_AVAILABLE:
 
825
  except Exception as e:
826
  return None, f"Export error: {e}"
827
 
828
+ def export_kmz_radar(forecast_hour: int, detail_level: int = 5, min_dbz: float = 0.0):
829
+ """Export a KMZ (KML + PNG) GroundOverlay of HRRR radar reflectivity for the given forecast hour.
830
+
831
+ Uses gx:LatLonQuad for the four-corner overlay to preserve orientation.
832
+ Returns (kmz_path, message)."""
833
+ try:
834
+ if not HERBIE_AVAILABLE:
835
+ return None, "Herbie is not available"
836
+
837
+ ds = fetch_real_hrrr_data('REFC:entire atmosphere', int(forecast_hour))
838
+ if isinstance(ds, tuple):
839
+ ds = ds[0]
840
+ grid = process_hrrr_grid(ds, target_cells={1:20000,2:40000,3:60000,4:90000,5:120000}.get(int(detail_level), 120000), param_type='radar', min_threshold=float(min_dbz))
841
+ if not grid:
842
+ return None, "Radar grid not available"
843
+
844
+ lat2d = grid['lat2d']
845
+ lon2d = grid['lon2d']
846
+ z2d = grid['z2d']
847
+
848
+ # Render PNG bytes
849
+ try:
850
+ import io
851
+ import matplotlib
852
+ matplotlib.use('Agg', force=True)
853
+ import matplotlib.pyplot as plt
854
+ zmask = np.ma.masked_invalid(z2d)
855
+ cmap = build_mpl_colormap(get_radar_colorscale())
856
+ ny, nx = z2d.shape
857
+ scale_map = {1: 1.2, 2: 1.6, 3: 2.0, 4: 3.0, 5: 4.0}
858
+ scale = scale_map.get(int(detail_level), 2.0)
859
+ width = int(nx * scale)
860
+ height = int(ny * scale)
861
+ dpi = 100
862
+ fig_img = plt.figure(figsize=(width / dpi, height / dpi), dpi=dpi)
863
+ fig_img.patch.set_alpha(0.0)
864
+ ax = fig_img.add_axes([0, 0, 1, 1])
865
+ ax.patch.set_alpha(0.0)
866
+ ax.imshow(zmask, cmap=cmap, vmin=0, vmax=65, origin='upper', interpolation='bilinear')
867
+ ax.axis('off')
868
+ buf = io.BytesIO()
869
+ fig_img.savefig(buf, format='png', dpi=dpi, transparent=True)
870
+ plt.close(fig_img)
871
+ png_bytes = buf.getvalue()
872
+ except Exception as e:
873
+ return None, f"PNG render error: {e}"
874
+
875
+ # Corners TL, TR, BR, BL
876
+ lat_tl, lon_tl = float(lat2d[0, 0]), float(lon2d[0, 0])
877
+ lat_tr, lon_tr = float(lat2d[0, -1]), float(lon2d[0, -1])
878
+ lat_br, lon_br = float(lat2d[-1, -1]), float(lon2d[-1, -1])
879
+ lat_bl, lon_bl = float(lat2d[-1, 0]), float(lon2d[-1, 0])
880
+
881
+ kml = f"""
882
+ <?xml version="1.0" encoding="UTF-8"?>
883
+ <kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
884
+ <GroundOverlay>
885
+ <name>HRRR Radar Reflectivity f{int(forecast_hour):02d}</name>
886
+ <color>ffffffff</color>
887
+ <Icon><href>overlay.png</href></Icon>
888
+ <gx:LatLonQuad>
889
+ <coordinates>
890
+ {lon_tl:.6f},{lat_tl:.6f},0 {lon_tr:.6f},{lat_tr:.6f},0 {lon_br:.6f},{lat_br:.6f},0 {lon_bl:.6f},{lat_bl:.6f},0
891
+ </coordinates>
892
+ </gx:LatLonQuad>
893
+ </GroundOverlay>
894
+ </kml>
895
+ """
896
+
897
+ import zipfile
898
+ os.makedirs('exports', exist_ok=True)
899
+ kmz_path = os.path.join('exports', f"hrrr_radar_f{int(forecast_hour):02d}.kmz")
900
+ with zipfile.ZipFile(kmz_path, 'w', zipfile.ZIP_DEFLATED) as zf:
901
+ zf.writestr('doc.kml', kml)
902
+ zf.writestr('overlay.png', png_bytes)
903
+ return kmz_path, None
904
+ except Exception as e:
905
+ return None, f"KMZ export error: {e}"
906
+
907
  def download_raw_grib(forecast_hour: int):
908
  """Return a copy-path under ./exports for the raw HRRR GRIB2 file used for REFC at the given forecast hour."""
909
  try:
 
1547
  # Optional animation and Leaflet overlay
1548
  gif_path = None
1549
  leaflet_html = ""
1550
+ leaflet_static = ""
1551
  if animate_forecast:
1552
  try:
1553
  gif_path, _ = generate_radar_animation_gif(detail_level=int(detail_level), min_dbz=float(min_dbz))
 
1566
  except Exception as e:
1567
  leaflet_html = f"<div style='padding:8px;color:#900'>Leaflet overlay build failed: {str(e)}</div>"
1568
 
1569
+ # Always build static overlay for side-by-side comparison
1570
+ try:
1571
+ leaflet_static = build_leaflet_static_overlay_html(LAST_RADAR_GRID, detail_level=int(detail_level))
1572
+ except Exception as e:
1573
+ leaflet_static = f"<div style='padding:8px;color:#900'>Leaflet static overlay failed: {str(e)}</div>"
1574
+
1575
+ return status, weather_map, gif_path, leaflet_html, leaflet_static
1576
 
1577
  except Exception as e:
1578
  print(f"Update error: {e}")
 
1583
  error_fig.add_annotation(text=f"Update failed: {str(e)}", x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False)
1584
  error_fig.update_layout(height=300)
1585
 
1586
+ return f"## Error\n{str(e)}", error_fig, None, "", ""
1587
 
1588
  # Stable interface - single map only
1589
  with gr.Blocks(title="HRRR Weather + Radar") as app:
 
1657
 
1658
  with gr.Column():
1659
  status_text = gr.Markdown("Click button to fetch HRRR weather + radar data")
1660
+ with gr.Row():
1661
+ weather_map = gr.Plot()
1662
+ leaflet_static = gr.HTML(label="Leaflet Static Overlay")
1663
  animation_view = gr.Image(label="Radar Animation (0–18h)")
1664
  leaflet_overlay = gr.HTML(label="Leaflet Overlay")
1665
  export_file = gr.File(label="GRIB2 Export", visible=True)
 
1669
  update_btn.click(
1670
  fn=update_display,
1671
  inputs=[location, forecast_hour, parameter, show_radar_overlay, detail_level, min_dbz, animate_forecast],
1672
+ outputs=[status_text, weather_map, animation_view, leaflet_overlay, leaflet_static]
1673
  )
1674
 
1675
  # Auto-update when toggling radar
1676
  show_radar_overlay.change(
1677
  fn=update_display,
1678
  inputs=[location, forecast_hour, parameter, show_radar_overlay, detail_level, min_dbz, animate_forecast],
1679
+ outputs=[status_text, weather_map, animation_view, leaflet_overlay, leaflet_static]
1680
  )
1681
 
1682
  # Export GRIB button
 
1699
  outputs=[export_file]
1700
  )
1701
 
1702
+ # Export KMZ overlay
1703
+ export_kmz_btn = gr.Button("Export KMZ Overlay")
1704
+ kmz_file = gr.File(label="KMZ Overlay", visible=True)
1705
+
1706
+ def _export_kmz_handler(forecast_hour, detail_level, min_dbz):
1707
+ path, msg = export_kmz_radar(int(forecast_hour), int(detail_level), float(min_dbz))
1708
+ if path:
1709
+ return path
1710
+ else:
1711
+ import os
1712
+ os.makedirs('exports', exist_ok=True)
1713
+ err_path = f"exports/kmz_error.txt"
1714
+ with open(err_path, 'w') as f:
1715
+ f.write(msg or 'KMZ export failed')
1716
+ return err_path
1717
+
1718
+ export_kmz_btn.click(
1719
+ fn=_export_kmz_handler,
1720
+ inputs=[forecast_hour, detail_level, min_dbz],
1721
+ outputs=[kmz_file]
1722
+ )
1723
+
1724
  def _download_raw_handler(forecast_hour):
1725
  path, msg = download_raw_grib(int(forecast_hour))
1726
  if path: