Spaces:
Sleeping
Sleeping
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
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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: '© 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
| 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:
|