import os from collections import defaultdict from pathlib import Path import json try: import mapbox_vector_tile except ModuleNotFoundError: # pragma: no cover - dependency guard for runtime environments mapbox_vector_tile = None try: import mercantile except ModuleNotFoundError: # pragma: no cover - dependency guard for runtime environments mercantile = None PROJECT_ROOT = Path(__file__).resolve().parent.parent GRID_DIR = PROJECT_ROOT / "data" / "grid" OUTPUT_DIR = Path("/home/wwwroot/weather") DEFAULT_ZOOMS = [2, 4, 6, 8, 10, 12] def get_zoom_levels(): raw_value = os.environ.get("WEATHER_TILE_ZOOMS", "") if not raw_value.strip(): return DEFAULT_ZOOMS zooms = [] for chunk in raw_value.split(","): chunk = chunk.strip() if not chunk: continue zoom = int(chunk) if zoom < 0: raise ValueError(f"Invalid zoom level: {zoom}") zooms.append(zoom) if not zooms: raise ValueError("WEATHER_TILE_ZOOMS did not contain any usable zoom levels") return sorted(set(zooms)) def load_grid(path): with path.open(encoding="utf-8") as file_handle: data = json.load(file_handle) return data["time"], data["grid"] def get_grid_field(grid, field_name, latitudes, longitudes): values = grid.get(field_name) if values is not None: return values return [[0.0 for _ in longitudes] for _ in latitudes] def grid_to_features(grid): latitudes = grid["lat"] longitudes = grid["lon"] wind_speed = grid["wind_speed"] wind_dir = grid["wind_dir"] rain = grid["rain"] temp = grid["temp"] pressure = grid["pressure"] wave_h = get_grid_field(grid, "wave_h", latitudes, longitudes) wave_dir = get_grid_field(grid, "wave_dir", latitudes, longitudes) wave_period = get_grid_field(grid, "wave_period", latitudes, longitudes) features = [] for lat_index, latitude in enumerate(latitudes): for lon_index, longitude in enumerate(longitudes): features.append( { "geometry": {"type": "Point", "coordinates": [longitude, latitude]}, "properties": { "ws": wind_speed[lat_index][lon_index], "wd": wind_dir[lat_index][lon_index], "r": rain[lat_index][lon_index], "t": temp[lat_index][lon_index], "p": pressure[lat_index][lon_index], "wh": wave_h[lat_index][lon_index], "wdir": wave_dir[lat_index][lon_index], "wp": wave_period[lat_index][lon_index], }, } ) return features def bucket_features_by_tile(features, zoom): buckets = defaultdict(list) for feature in features: longitude, latitude = feature["geometry"]["coordinates"] tile = mercantile.tile(longitude, latitude, zoom) buckets[(tile.x, tile.y)].append(feature) return buckets def write_tile(tile_time, zoom, tile_x, tile_y, features): bounds = mercantile.bounds(mercantile.Tile(x=tile_x, y=tile_y, z=zoom)) tile_dir = OUTPUT_DIR / tile_time / str(zoom) / str(tile_x) tile_dir.mkdir(parents=True, exist_ok=True) tile_path = tile_dir / f"{tile_y}.pbf" layer = {"name": "weather", "features": features} tile_data = mapbox_vector_tile.encode( layer, default_options={ "quantize_bounds": (bounds.west, bounds.south, bounds.east, bounds.north), }, ) tile_path.write_bytes(tile_data) print("tile", tile_time, zoom, tile_x, tile_y) def generate_tiles(tile_time, features): for zoom in get_zoom_levels(): buckets = bucket_features_by_tile(features, zoom) for (tile_x, tile_y), tile_features in buckets.items(): write_tile(tile_time, zoom, tile_x, tile_y, tile_features) def main(): if mapbox_vector_tile is None or mercantile is None: raise RuntimeError("mapbox-vector-tile and mercantile are required to generate vector tiles") for path in sorted(GRID_DIR.glob("*.json")): print("processing", path.name) tile_time, grid = load_grid(path) features = grid_to_features(grid) generate_tiles(tile_time, features) if __name__ == "__main__": main()