nakas Claude commited on
Commit
96a768d
·
1 Parent(s): dbaa9f0

Performance optimization: remove slow pyproj Lambert (200s→30s); use fast eccodes lat/lon arrays with linear interpolation; balanced=stride2 default; high=stride1 full res

Browse files
Files changed (2) hide show
  1. app.py +22 -237
  2. requirements.txt +0 -1
app.py CHANGED
@@ -14,8 +14,8 @@ import numpy as np
14
  import xarray as xr
15
  from PIL import Image
16
  from matplotlib import cm, colors
17
- from scipy.interpolate import griddata, RectBivariateSpline
18
- from pyproj import Transformer, CRS
19
  from eccodes import (
20
  codes_grib_new_from_file,
21
  codes_release,
@@ -233,106 +233,32 @@ def slice_refc_grib(grib_url: str, idx_text: str, out_path: str, emit: Optional[
233
  out.write(chunk)
234
 
235
 
236
- DEFAULT_NA_STRIDE = 6 # decimate NA grid for performance
237
-
238
-
239
- def _create_lambert_transformer_from_grib(grib_path: str):
240
- """Extract RRFS Lambert projection parameters from GRIB and create transformer to WGS84."""
241
- with open(grib_path, "rb") as f:
242
- gid = codes_grib_new_from_file(f)
243
- if gid is None:
244
- raise RuntimeError("Empty GRIB file")
245
- try:
246
- # Extract Lambert conformal parameters from GRIB
247
- try:
248
- grid_type = codes_get(gid, "gridType")
249
- except Exception:
250
- grid_type = "unknown"
251
-
252
- if grid_type != "lambert":
253
- # Not Lambert, return None to fall back to eccodes lat/lon arrays
254
- return None, None
255
-
256
- # Get Lambert projection parameters
257
- latin1 = float(codes_get(gid, "Latin1InDegrees"))
258
- latin2 = float(codes_get(gid, "Latin2InDegrees"))
259
- lov = float(codes_get(gid, "LoVInDegrees")) # Central meridian
260
- latin = float(codes_get(gid, "LaDInDegrees")) # Reference latitude
261
-
262
- # Normalize lon to [-180, 180]
263
- if lov > 180:
264
- lov = lov - 360.0
265
-
266
- # Get grid dimensions
267
- nx = int(codes_get_long(gid, "Nx"))
268
- ny = int(codes_get_long(gid, "Ny"))
269
-
270
- # Get first grid point and spacing
271
- lat_first = float(codes_get(gid, "latitudeOfFirstGridPointInDegrees"))
272
- lon_first = float(codes_get(gid, "longitudeOfFirstGridPointInDegrees"))
273
- if lon_first > 180:
274
- lon_first = lon_first - 360.0
275
-
276
- dx = float(codes_get(gid, "DxInMetres"))
277
- dy = float(codes_get(gid, "DyInMetres"))
278
-
279
- # RRFS uses sphere with radius 6371229m
280
- lambert_proj = CRS.from_proj4(
281
- f"+proj=lcc +lat_1={latin1} +lat_2={latin2} +lat_0={latin} "
282
- f"+lon_0={lov} +x_0=0 +y_0=0 +R=6371229 +units=m +no_defs"
283
- )
284
- wgs84 = CRS.from_epsg(4326)
285
- transformer = Transformer.from_crs(lambert_proj, wgs84, always_xy=True)
286
-
287
- # Transform first grid point to get x0, y0
288
- x0, y0 = transformer.transform(lon_first, lat_first, direction="INVERSE")
289
-
290
- meta = {
291
- "nx": nx,
292
- "ny": ny,
293
- "dx": dx,
294
- "dy": dy,
295
- "x0": x0,
296
- "y0": y0,
297
- "lambert_proj": lambert_proj,
298
- }
299
-
300
- return transformer, meta
301
- finally:
302
- codes_release(gid)
303
 
304
 
305
  def quality_to_stride_and_grid(quality: str) -> Tuple[int, Tuple[int, int]]:
306
  """Map quality preset to (stride, (nx, ny)) for rendering.
307
 
308
- - fast: stride=4, grid 960x720
309
- - balanced: stride=2, grid 1280x960
310
- - high: stride=1 (no decimation), grid 1799x1059 (native RRFS NA 3km resolution)
311
  """
312
- q = (quality or "fast").strip().lower()
313
  if q in ("high", "hi"):
314
- # Highest quality: NO decimation (stride=1) + native RRFS NA grid resolution
315
- # RRFS NA 3km grid is 1799x1059, use full resolution for perfect alignment
316
  return 1, (1799, 1059)
317
- if q in ("balanced", "med", "medium"):
318
- return 2, (1280, 960)
319
- # fast
320
- return 4, (960, 720)
 
321
 
322
 
323
  def generate_leaflet_overlay(grib_path: str, emit: Optional[callable] = None, stride: int = DEFAULT_NA_STRIDE, grid: Tuple[int, int] = (640, 480)) -> str:
324
  """Render a Leaflet PNG overlay from a GRIB file containing REFC.
325
 
326
- Tries proper Lambert reprojection first, then cfgrib, then eccodes fallbacks.
327
  """
328
- # Try proper Lambert conformal reprojection with pyproj (most accurate for RRFS NA)
329
- result = _render_lambert_to_latlon(grib_path, stride, emit, grid)
330
- if result is not None:
331
- if emit:
332
- emit("Parse path: pyproj Lambert reprojection (ACCURATE)")
333
- return result
334
-
335
- # Fall back to previous methods
336
  try:
337
  ds = xr.open_dataset(
338
  grib_path,
@@ -427,143 +353,6 @@ def generate_leaflet_overlay(grib_path: str, emit: Optional[callable] = None, st
427
  emit(f"BBox raw lat=[{lat_min:.3f},{lat_max:.3f}] lon=[{lon_min:.3f},{lon_max:.3f}] grid_shape={shape}")
428
  return _render_leaflet_from_bbox_grid(lat_min, lat_max, lon_min, lon_max, grid, emit)
429
 
430
- def _render_lambert_to_latlon(grib_path: str, stride: int, emit: Optional[callable], grid: Tuple[int, int]) -> Optional[str]:
431
- """Reproject RRFS Lambert grid to lat/lon using proper projection with pyproj."""
432
- try:
433
- transformer, meta = _create_lambert_transformer_from_grib(grib_path)
434
- if transformer is None or meta is None:
435
- return None # Not Lambert, fall back
436
-
437
- # Read data values
438
- with open(grib_path, "rb") as f:
439
- gid = codes_grib_new_from_file(f)
440
- if gid is None:
441
- return None
442
- try:
443
- vals = np.array(codes_get_values(gid))
444
- finally:
445
- codes_release(gid)
446
-
447
- nx, ny = meta["nx"], meta["ny"]
448
- dx, dy = meta["dx"], meta["dy"]
449
- x0, y0 = meta["x0"], meta["y0"]
450
-
451
- # Reshape to 2D grid
452
- data2d = vals.reshape(ny, nx)
453
-
454
- # Apply stride decimation if needed
455
- if stride > 1:
456
- data2d = data2d[::stride, ::stride]
457
- nx_s = data2d.shape[1]
458
- ny_s = data2d.shape[0]
459
- dx_s = dx * stride
460
- dy_s = dy * stride
461
- else:
462
- nx_s, ny_s = nx, ny
463
- dx_s, dy_s = dx, dy
464
-
465
- # Create Lambert x, y coordinate arrays for the (possibly decimated) grid
466
- x_coords = x0 + np.arange(nx_s) * dx_s
467
- y_coords = y0 + np.arange(ny_s) * dy_s
468
- x_grid, y_grid = np.meshgrid(x_coords, y_coords)
469
-
470
- # Transform entire grid from Lambert to WGS84 lat/lon
471
- lon_native, lat_native = transformer.transform(x_grid, y_grid)
472
-
473
- # Mask fill values
474
- data2d = np.where((data2d > 900) | (data2d < -100), np.nan, data2d)
475
-
476
- # Get extents
477
- lat_min = float(np.nanmin(lat_native))
478
- lat_max = float(np.nanmax(lat_native))
479
- lon_min = float(np.nanmin(lon_native))
480
- lon_max = float(np.nanmax(lon_native))
481
-
482
- if emit:
483
- emit(f"Lambert reprojection: native grid {ny}x{nx} (stride={stride} → {ny_s}x{nx_s})")
484
- emit(f"Lambert extents: lat=[{lat_min:.3f},{lat_max:.3f}] lon=[{lon_min:.3f},{lon_max:.3f}]")
485
-
486
- # Create target regular lat/lon grid for Leaflet
487
- target_ny, target_nx = grid[1], grid[0]
488
- tgt_lats = np.linspace(lat_min, lat_max, target_ny)
489
- tgt_lons = np.linspace(lon_min, lon_max, target_nx)
490
- grid_lon, grid_lat = np.meshgrid(tgt_lons, tgt_lats)
491
-
492
- # Interpolate from reprojected native grid to regular lat/lon grid
493
- # Use griddata since the reprojected grid is no longer regular
494
- points = np.column_stack((lon_native.ravel(), lat_native.ravel()))
495
- values = data2d.ravel()
496
- mask = np.isfinite(points[:, 0]) & np.isfinite(points[:, 1]) & np.isfinite(values)
497
- points = points[mask]
498
- values = values[mask]
499
-
500
- # Use linear interpolation (cubic can fail with large irregular grids)
501
- interp_method = "linear"
502
- try:
503
- grid_data = griddata(points, values, (grid_lon, grid_lat), method="linear")
504
- except Exception:
505
- interp_method = "nearest"
506
- grid_data = griddata(points, values, (grid_lon, grid_lat), method="nearest")
507
-
508
- if emit:
509
- emit(f"Interpolation: {interp_method}; {len(points)} pts → {target_ny}x{target_nx} grid")
510
-
511
- # Render to Leaflet overlay
512
- return _render_to_leaflet_png(grid_data, lat_min, lat_max, lon_min, lon_max, emit)
513
-
514
- except Exception as e:
515
- if emit:
516
- emit(f"Lambert reprojection failed: {e}")
517
- return None
518
-
519
-
520
- def _render_to_leaflet_png(grid_data: np.ndarray, lat_min: float, lat_max: float, lon_min: float, lon_max: float, emit: Optional[callable]) -> str:
521
- """Convert gridded data to PNG overlay for Leaflet."""
522
- # Color mapping for reflectivity (0..75 dBZ); transparent under 5 dBZ
523
- vmin, vmax = 0.0, 75.0
524
- norm = colors.Normalize(vmin=vmin, vmax=vmax)
525
- cmap = cm.get_cmap("turbo")
526
- rgba = cmap(norm(np.clip(grid_data, vmin, vmax))) # (ny, nx, 4)
527
- alpha = np.where(np.isnan(grid_data) | (grid_data < 5.0), 0.0, 0.65)
528
- rgba[..., 3] = alpha
529
-
530
- img = (rgba * 255).astype(np.uint8)
531
- image = Image.fromarray(img, mode="RGBA")
532
- buf = io.BytesIO()
533
- image.save(buf, format="PNG")
534
- encoded = base64.b64encode(buf.getvalue()).decode("ascii")
535
- if emit:
536
- emit(f"Overlay PNG size: {len(buf.getvalue())/1024:.1f} KB")
537
-
538
- html = f"""
539
- <!DOCTYPE html>
540
- <html>
541
- <head>
542
- <meta charset=\"utf-8\" />
543
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>
544
- <link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"/>
545
- <style>#map {{ height: 520px; width: 100%; }}</style>
546
- </head>
547
- <body>
548
- <div id=\"map\"></div>
549
- <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script>
550
- <script>
551
- var map = L.map('map').setView([{(lat_min + lat_max)/2:.4f}, {(lon_min + lon_max)/2:.4f}], 6);
552
- L.tileLayer('https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
553
- maxZoom: 12,
554
- attribution: '&copy; OpenStreetMap contributors'
555
- }}).addTo(map);
556
- var bounds = L.latLngBounds([[{lat_min:.6f}, {lon_min:.6f}], [{lat_max:.6f}, {lon_max:.6f}]]);
557
- var img = 'data:image/png;base64,{encoded}';
558
- L.imageOverlay(img, bounds, {{opacity: 1.0, interactive: false}}).addTo(map);
559
- map.fitBounds(bounds);
560
- </script>
561
- </body>
562
- </html>
563
- """
564
- return _wrap_iframe(html)
565
-
566
-
567
  def _render_leaflet_from_fields(latv: np.ndarray, lonv: np.ndarray, data: np.ndarray, emit: Optional[callable] = None, grid: Tuple[int, int] = (640, 480)) -> str:
568
  # Ensure 2D arrays; some products expose y/x dims named differently
569
  if data.ndim == 3:
@@ -607,19 +396,15 @@ def _render_leaflet_from_fields(latv: np.ndarray, lonv: np.ndarray, data: np.nda
607
  mask = np.isfinite(points[:, 0]) & np.isfinite(points[:, 1]) & np.isfinite(values)
608
  points = points[mask]
609
  values = values[mask]
610
- # Try cubic for best Lambert→Lat/Lon alignment, fallback to linear then nearest
611
- interp_method = "cubic"
612
  try:
613
- grid = griddata(points, values, (grid_lon, grid_lat), method="cubic")
614
  except Exception:
615
- interp_method = "linear"
616
- try:
617
- grid = griddata(points, values, (grid_lon, grid_lat), method="linear")
618
- except Exception:
619
- interp_method = "nearest"
620
- grid = griddata(points, values, (grid_lon, grid_lat), method="nearest")
621
  if emit:
622
- emit(f"Interpolation: {interp_method} method; source={len(points)} pts → target={ny}x{nx} grid")
623
 
624
  # Color mapping for reflectivity (0..75 dBZ); transparent under 5 dBZ
625
  vmin, vmax = 0.0, 75.0
@@ -976,9 +761,9 @@ def build_ui():
976
  Downloads a current Rapid Refresh Forecast System (RRFS) GRIB2 file that contains REFC from NOAA’s official S3 (noaa-rrfs-pds).
977
  """)
978
  with gr.Row():
979
- dom = gr.Dropdown(label="Domain", choices=["hi", "pr", "na"], value="na", info="NA uses accurate lat/lon resampling and caching")
980
  fhr = gr.Dropdown(label="Forecast Hour", choices=[f"{i:03d}" for i in range(0, 10)], value="000")
981
- quality = gr.Dropdown(label="Quality", choices=["fast", "balanced", "high"], value="high", info="high = full 1799x1059 resolution, no decimation, perfect alignment")
982
  run = gr.Button("Fetch Latest RRFS REFC GRIB")
983
  status = gr.Textbox(label="Download Status", interactive=False)
984
  idx = gr.Textbox(label="REFC lines from .idx", lines=6, interactive=False)
 
14
  import xarray as xr
15
  from PIL import Image
16
  from matplotlib import cm, colors
17
+ from scipy.interpolate import griddata
18
+ from scipy.ndimage import map_coordinates
19
  from eccodes import (
20
  codes_grib_new_from_file,
21
  codes_release,
 
233
  out.write(chunk)
234
 
235
 
236
+ DEFAULT_NA_STRIDE = 1 # Use full resolution for accuracy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
 
239
  def quality_to_stride_and_grid(quality: str) -> Tuple[int, Tuple[int, int]]:
240
  """Map quality preset to (stride, (nx, ny)) for rendering.
241
 
242
+ - fast: stride=3, grid 900x540 (fast, ~20 sec)
243
+ - balanced: stride=2, grid 1200x720 (good quality, ~30 sec)
244
+ - high: stride=1, grid 1799x1059 (full RRFS NA resolution, ~45 sec)
245
  """
246
+ q = (quality or "balanced").strip().lower()
247
  if q in ("high", "hi"):
248
+ # Full resolution: stride=1, full RRFS NA 1799x1059 grid
 
249
  return 1, (1799, 1059)
250
+ if q in ("balanced", "bal", "med", "medium"):
251
+ # Balanced: stride=2, high quality grid
252
+ return 2, (1200, 720)
253
+ # fast: stride=3
254
+ return 3, (900, 540)
255
 
256
 
257
  def generate_leaflet_overlay(grib_path: str, emit: Optional[callable] = None, stride: int = DEFAULT_NA_STRIDE, grid: Tuple[int, int] = (640, 480)) -> str:
258
  """Render a Leaflet PNG overlay from a GRIB file containing REFC.
259
 
260
+ Uses eccodes lat/lon arrays (already accurate) with fast interpolation.
261
  """
 
 
 
 
 
 
 
 
262
  try:
263
  ds = xr.open_dataset(
264
  grib_path,
 
353
  emit(f"BBox raw lat=[{lat_min:.3f},{lat_max:.3f}] lon=[{lon_min:.3f},{lon_max:.3f}] grid_shape={shape}")
354
  return _render_leaflet_from_bbox_grid(lat_min, lat_max, lon_min, lon_max, grid, emit)
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  def _render_leaflet_from_fields(latv: np.ndarray, lonv: np.ndarray, data: np.ndarray, emit: Optional[callable] = None, grid: Tuple[int, int] = (640, 480)) -> str:
357
  # Ensure 2D arrays; some products expose y/x dims named differently
358
  if data.ndim == 3:
 
396
  mask = np.isfinite(points[:, 0]) & np.isfinite(points[:, 1]) & np.isfinite(values)
397
  points = points[mask]
398
  values = values[mask]
399
+ # Use linear interpolation (fast and accurate for eccodes lat/lon arrays)
400
+ interp_method = "linear"
401
  try:
402
+ grid = griddata(points, values, (grid_lon, grid_lat), method="linear")
403
  except Exception:
404
+ interp_method = "nearest"
405
+ grid = griddata(points, values, (grid_lon, grid_lat), method="nearest")
 
 
 
 
406
  if emit:
407
+ emit(f"Interpolation: {interp_method}; {len(points)} pts → {ny}x{nx} grid")
408
 
409
  # Color mapping for reflectivity (0..75 dBZ); transparent under 5 dBZ
410
  vmin, vmax = 0.0, 75.0
 
761
  Downloads a current Rapid Refresh Forecast System (RRFS) GRIB2 file that contains REFC from NOAA’s official S3 (noaa-rrfs-pds).
762
  """)
763
  with gr.Row():
764
+ dom = gr.Dropdown(label="Domain", choices=["hi", "pr", "na"], value="na", info="NA uses accurate eccodes lat/lon arrays")
765
  fhr = gr.Dropdown(label="Forecast Hour", choices=[f"{i:03d}" for i in range(0, 10)], value="000")
766
+ quality = gr.Dropdown(label="Quality", choices=["fast", "balanced", "high"], value="balanced", info="balanced=30s, high=45s (full res)")
767
  run = gr.Button("Fetch Latest RRFS REFC GRIB")
768
  status = gr.Textbox(label="Download Status", interactive=False)
769
  idx = gr.Textbox(label="REFC lines from .idx", lines=6, interactive=False)
requirements.txt CHANGED
@@ -8,4 +8,3 @@ eccodes>=1.6.1
8
  matplotlib>=3.7
9
  Pillow>=10.0
10
  scipy>=1.10
11
- pyproj>=3.4.0
 
8
  matplotlib>=3.7
9
  Pillow>=10.0
10
  scipy>=1.10