| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import re |
| import warnings |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Dict, List, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
| import rasterio |
| import xarray as xr |
| from rasterio.enums import Resampling |
| from rasterio.transform import from_bounds |
| from rasterio.warp import reproject, transform, transform_bounds |
| from sklearn.neighbors import NearestNeighbors |
|
|
| from cache_helpers import ensure_dirs, resampling_from_name, split_for_timestamp |
|
|
|
|
| HRRR_RE = re.compile(r"hrrr\.(\d{8})\.t(\d{2})z\.wrfsfcf00\.grib2$") |
|
|
| VAR_GROUPS = { |
| "level2": { |
| "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 2}, |
| "vars": ["t2m", "d2m", "sh2", "r2", "pt"], |
| }, |
| "level10": { |
| "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 10}, |
| "vars": ["u10", "v10", "max_10si"], |
| }, |
| "surface_instant": { |
| "filter_by_keys": {"typeOfLevel": "surface", "stepType": "instant"}, |
| "vars": ["cape", "sp", "blh", "vis", "prate", "gust", "t"], |
| }, |
| "surface_accum": { |
| "filter_by_keys": {"typeOfLevel": "surface", "stepType": "accum"}, |
| "vars": ["tp", "ssrun", "bgrun"], |
| }, |
| } |
|
|
|
|
| @dataclass |
| class SampleRecord: |
| issue_ts: pd.Timestamp |
| target_ts: pd.Timestamp |
| hrrr_path: Path |
| label_path: Path |
|
|
|
|
| @dataclass |
| class RegionalContext: |
| config: Dict[str, object] |
| cache_root: Path |
| firms_dir: Path |
| hrrr_roots: List[Path] |
| static_layers: Dict[str, Dict[str, str]] |
| sample_dir: Path |
| static_dir: Path |
| manifest_dir: Path |
| split_dir: Path |
|
|
|
|
| def load_json(path: Path) -> Dict[str, object]: |
| return json.loads(path.read_text(encoding="utf-8")) |
|
|
|
|
| def build_context(config: Dict[str, object]) -> RegionalContext: |
| cache_root = Path(config["cache_root"]) |
| sample_dir = cache_root / "inputs" |
| static_dir = cache_root / "static" |
| manifest_dir = cache_root / "manifests" |
| split_dir = cache_root / "splits" |
| ensure_dirs([sample_dir, static_dir, manifest_dir, split_dir]) |
| return RegionalContext( |
| config=config, |
| cache_root=cache_root, |
| firms_dir=Path(config["firms_dir"]), |
| hrrr_roots=[Path(p) for p in config["hrrr_roots"]], |
| static_layers=config["static_layers"], |
| sample_dir=sample_dir, |
| static_dir=static_dir, |
| manifest_dir=manifest_dir, |
| split_dir=split_dir, |
| ) |
|
|
|
|
| def build_projected_grid(bounds: Dict[str, float], target_crs: str, resolution_m: float) -> Tuple[np.ndarray, np.ndarray]: |
| west, south, east, north = transform_bounds( |
| "EPSG:4326", |
| target_crs, |
| float(bounds["lon_min"]), |
| float(bounds["lat_min"]), |
| float(bounds["lon_max"]), |
| float(bounds["lat_max"]), |
| densify_pts=21, |
| ) |
| res = float(resolution_m) |
| x_asc = np.arange(west + 0.5 * res, east, res, dtype=np.float32) |
| y_asc = np.arange(south + 0.5 * res, north, res, dtype=np.float32) |
| if x_asc.size == 0 or y_asc.size == 0: |
| raise RuntimeError(f"Empty projected grid for resolution_m={resolution_m}") |
| return y_asc[::-1].copy(), x_asc.copy() |
|
|
|
|
| def projected_transform(y_desc: np.ndarray, x_asc: np.ndarray) -> rasterio.Affine: |
| res_y = float(np.median(np.abs(np.diff(y_desc)))) |
| res_x = float(np.median(np.diff(x_asc))) |
| west = float(x_asc[0] - 0.5 * res_x) |
| east = float(x_asc[-1] + 0.5 * res_x) |
| south = float(y_desc[-1] - 0.5 * res_y) |
| north = float(y_desc[0] + 0.5 * res_y) |
| return from_bounds(west, south, east, north, len(x_asc), len(y_desc)) |
|
|
|
|
| def projected_edges(values_asc: np.ndarray) -> np.ndarray: |
| step = float(np.median(np.diff(values_asc))) |
| edges = np.empty(values_asc.size + 1, dtype=np.float64) |
| edges[1:-1] = 0.5 * (values_asc[:-1] + values_asc[1:]) |
| edges[0] = values_asc[0] - 0.5 * step |
| edges[-1] = values_asc[-1] + 0.5 * step |
| return edges |
|
|
|
|
| def list_hrrr_issue_paths(roots: List[Path]) -> Dict[pd.Timestamp, Path]: |
| out: Dict[pd.Timestamp, Path] = {} |
| for root in roots: |
| if not root.exists(): |
| continue |
| for path in root.rglob("*.grib2"): |
| m = HRRR_RE.match(path.name) |
| if not m: |
| continue |
| date_part, hour_part = m.groups() |
| ts = pd.to_datetime(f"{date_part}{hour_part}", format="%Y%m%d%H") |
| out.setdefault(ts, path) |
| return dict(sorted(out.items())) |
|
|
|
|
| def build_sample_records(ctx: RegionalContext) -> List[SampleRecord]: |
| hours = {int(v) for v in ctx.config.get("issue_hours", [0, 6, 12, 18])} |
| lead_hours = int(ctx.config["target_offset_hours"]) |
| hrrr_index = list_hrrr_issue_paths(ctx.hrrr_roots) |
| records: List[SampleRecord] = [] |
| for item in ctx.config["input_ranges"]: |
| for day in pd.date_range(item["start"], item["end"], freq="D"): |
| for hour in sorted(hours): |
| issue_ts = pd.Timestamp(year=day.year, month=day.month, day=day.day, hour=hour) |
| hrrr_path = hrrr_index.get(issue_ts) |
| if hrrr_path is None: |
| continue |
| target_ts = issue_ts + pd.Timedelta(hours=lead_hours) |
| label_path = ctx.firms_dir / f"{target_ts.strftime('%Y%m%d_%H')}.csv" |
| if not label_path.exists(): |
| continue |
| records.append( |
| SampleRecord( |
| issue_ts=issue_ts, |
| target_ts=target_ts, |
| hrrr_path=hrrr_path, |
| label_path=label_path, |
| ) |
| ) |
| if not records: |
| raise RuntimeError("No regional HRRR/FIRMS sample records found.") |
| return records |
|
|
|
|
| def open_hrrr_dataset(path: Path, group_name: str) -> xr.Dataset: |
| warnings.filterwarnings("ignore", category=FutureWarning) |
| |
| |
| |
| kwargs = { |
| "filter_by_keys": dict(VAR_GROUPS[group_name]["filter_by_keys"]), |
| "indexpath": "", |
| } |
| return xr.open_dataset(path, engine="cfgrib", backend_kwargs=kwargs) |
|
|
|
|
| def build_source_resampler(sample_grib: Path, bounds: Dict[str, float], target_crs: str, y_desc: np.ndarray, x_asc: np.ndarray) -> Dict[str, np.ndarray]: |
| ds = open_hrrr_dataset(sample_grib, "level2") |
| lat2d = np.asarray(ds["latitude"].values, dtype=np.float64) |
| lon2d = np.asarray(ds["longitude"].values, dtype=np.float64) |
| ds.close() |
| lon2d = np.where(lon2d > 180.0, lon2d - 360.0, lon2d) |
| buffer_deg = float(bounds.get("buffer_deg", 1.0)) |
| mask = ( |
| (lat2d >= float(bounds["lat_min"]) - buffer_deg) |
| & (lat2d <= float(bounds["lat_max"]) + buffer_deg) |
| & (lon2d >= float(bounds["lon_min"]) - buffer_deg) |
| & (lon2d <= float(bounds["lon_max"]) + buffer_deg) |
| ) |
| flat_idx = np.flatnonzero(mask.ravel()) |
| if flat_idx.size == 0: |
| raise RuntimeError("No HRRR source points remain after California bbox crop.") |
| src_lat = lat2d.ravel()[flat_idx] |
| src_lon = lon2d.ravel()[flat_idx] |
| src_x, src_y = transform("EPSG:4326", target_crs, src_lon.tolist(), src_lat.tolist()) |
| src_xy = np.column_stack([np.asarray(src_x, dtype=np.float32), np.asarray(src_y, dtype=np.float32)]) |
|
|
| yy, xx = np.meshgrid(y_desc, x_asc, indexing="ij") |
| tgt_xy = np.column_stack([xx.ravel().astype(np.float32), yy.ravel().astype(np.float32)]) |
| nn = NearestNeighbors(n_neighbors=1, algorithm="kd_tree") |
| nn.fit(src_xy) |
| _, gather = nn.kneighbors(tgt_xy, return_distance=True) |
| return { |
| "flat_idx": flat_idx.astype(np.int64), |
| "gather_idx": gather[:, 0].astype(np.int64), |
| "target_h": np.array([len(y_desc)], dtype=np.int64), |
| "target_w": np.array([len(x_asc)], dtype=np.int64), |
| } |
|
|
|
|
| def resample_field(field: np.ndarray, resampler: Dict[str, np.ndarray]) -> np.ndarray: |
| flat = np.asarray(field, dtype=np.float32).ravel()[resampler["flat_idx"]] |
| gathered = flat[resampler["gather_idx"]] |
| h = int(resampler["target_h"][0]) |
| w = int(resampler["target_w"][0]) |
| return np.nan_to_num(gathered.reshape(h, w), nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32) |
|
|
|
|
| def load_hrrr_cube(ctx: RegionalContext, records: List[SampleRecord], y_desc: np.ndarray, x_asc: np.ndarray) -> Tuple[np.ndarray, List[str]]: |
| weather_names = list(ctx.config["hrrr_vars"]) |
| weather = np.zeros((len(records), len(weather_names), len(y_desc), len(x_asc)), dtype=np.float32) |
| resampler = build_source_resampler(records[0].hrrr_path, ctx.config, str(ctx.config["target_crs"]), y_desc, x_asc) |
|
|
| var_to_group = {} |
| for group_name, spec in VAR_GROUPS.items(): |
| for name in spec["vars"]: |
| var_to_group[name] = group_name |
|
|
| for i, record in enumerate(records): |
| opened: Dict[str, xr.Dataset] = {} |
| try: |
| for j, name in enumerate(weather_names): |
| group = var_to_group.get(name) |
| if group is None: |
| raise KeyError(f"No HRRR group mapping for variable '{name}'") |
| ds = opened.get(group) |
| if ds is None: |
| ds = open_hrrr_dataset(record.hrrr_path, group) |
| opened[group] = ds |
| if name not in ds.data_vars: |
| raise KeyError(f"Variable '{name}' missing from {record.hrrr_path} in group '{group}'") |
| weather[i, j] = resample_field(np.asarray(ds[name].values, dtype=np.float32), resampler) |
| finally: |
| for ds in opened.values(): |
| ds.close() |
| if (i + 1) % 20 == 0 or (i + 1) == len(records): |
| print( |
| json.dumps( |
| { |
| "stage": "load_hrrr_cube_progress", |
| "loaded_samples": i + 1, |
| "total_samples": len(records), |
| "latest_issue_timestamp": record.issue_ts.isoformat(), |
| } |
| ), |
| flush=True, |
| ) |
| return weather, weather_names |
|
|
|
|
| def load_static_cube_projected(ctx: RegionalContext, y_desc: np.ndarray, x_asc: np.ndarray) -> Tuple[np.ndarray, List[str], np.ndarray]: |
| transform_dst = projected_transform(y_desc, x_asc) |
| shape = (len(y_desc), len(x_asc)) |
| res_y = float(np.median(np.abs(np.diff(y_desc)))) |
| res_x = float(np.median(np.diff(x_asc))) |
| west = float(x_asc[0] - 0.5 * res_x) |
| east = float(x_asc[-1] + 0.5 * res_x) |
| south = float(y_desc[-1] - 0.5 * res_y) |
| north = float(y_desc[0] + 0.5 * res_y) |
| target_crs = str(ctx.config["target_crs"]) |
|
|
| arrays: List[np.ndarray] = [] |
| valid_arrays: List[np.ndarray] = [] |
| names: List[str] = [] |
| for name, spec in ctx.static_layers.items(): |
| names.append(name) |
| dst = np.full(shape, np.nan, dtype=np.float32) |
| with rasterio.open(spec["path"]) as src: |
| src_bounds = transform_bounds(target_crs, src.crs, west, south, east, north, densify_pts=21) |
| window = src.window(*src_bounds).round_offsets().round_lengths() |
| source = src.read(1, window=window) |
| source_transform = src.window_transform(window) |
| reproject( |
| source=source, |
| destination=dst, |
| src_transform=source_transform, |
| src_crs=src.crs, |
| dst_transform=transform_dst, |
| dst_crs=target_crs, |
| src_nodata=src.nodata, |
| dst_nodata=np.nan, |
| resampling=resampling_from_name(spec["resampling"]), |
| ) |
| valid = np.isfinite(dst) & (dst > -9000.0) |
| valid_arrays.append(valid.astype(np.float32)) |
| dst = np.where(dst <= -9000.0, np.nan, dst) |
| dst = np.nan_to_num(dst, nan=0.0, posinf=0.0, neginf=0.0) |
| arrays.append(dst.astype(np.float32)) |
| valid_fraction = np.stack(valid_arrays, axis=0).mean(axis=0, keepdims=True).astype(np.float32) |
| return np.stack(arrays, axis=0), names, valid_fraction |
|
|
|
|
| def rasterize_firms_counts_projected(firms_path: Path, y_desc: np.ndarray, x_asc: np.ndarray, target_crs: str, bounds: Dict[str, float]) -> np.ndarray: |
| if not firms_path.exists(): |
| return np.zeros((len(y_desc), len(x_asc)), dtype=np.float32) |
| try: |
| df = pd.read_csv(firms_path, usecols=["latitude", "longitude", "type"]) |
| except ValueError: |
| df = pd.read_csv(firms_path) |
| if "type" in df.columns: |
| df = df[df["type"] == 0] |
| if df.empty: |
| return np.zeros((len(y_desc), len(x_asc)), dtype=np.float32) |
| df = df[ |
| (df["latitude"] >= float(bounds["lat_min"])) |
| & (df["latitude"] <= float(bounds["lat_max"])) |
| & (df["longitude"] >= float(bounds["lon_min"])) |
| & (df["longitude"] <= float(bounds["lon_max"])) |
| ] |
| if df.empty: |
| return np.zeros((len(y_desc), len(x_asc)), dtype=np.float32) |
|
|
| xs, ys = transform( |
| "EPSG:4326", |
| target_crs, |
| df["longitude"].astype(float).tolist(), |
| df["latitude"].astype(float).tolist(), |
| ) |
| y_asc = y_desc[::-1] |
| y_edges = projected_edges(y_asc) |
| x_edges = projected_edges(x_asc) |
| counts, _, _ = np.histogram2d(np.asarray(ys, dtype=np.float64), np.asarray(xs, dtype=np.float64), bins=[y_edges, x_edges]) |
| return counts.astype(np.float32)[::-1, :] |
|
|
|
|
| def write_cache( |
| ctx: RegionalContext, |
| records: List[SampleRecord], |
| weather: np.ndarray, |
| static: np.ndarray, |
| static_valid: np.ndarray, |
| y_desc: np.ndarray, |
| x_asc: np.ndarray, |
| weather_names: List[str], |
| static_names: List[str], |
| ) -> pd.DataFrame: |
| static_path = ctx.static_dir / "static_regional_phase1_v1.npz" |
| np.savez_compressed( |
| static_path, |
| static=static, |
| static_valid=static_valid, |
| lat=y_desc, |
| lon=x_asc, |
| coord_crs=np.array([str(ctx.config["target_crs"])], dtype=object), |
| static_names=np.array(static_names, dtype=object), |
| ) |
|
|
| h = int(len(y_desc)) |
| w = int(len(x_asc)) |
| empty_firewx = np.zeros((0, h, w), dtype=np.float32) |
| firewx_valid = np.ones((1, h, w), dtype=np.float32) |
| rows: List[Dict[str, object]] = [] |
| target_crs = str(ctx.config["target_crs"]) |
|
|
| for i, record in enumerate(records): |
| y_count = rasterize_firms_counts_projected(record.label_path, y_desc, x_asc, target_crs, ctx.config) |
| y_occ = (y_count > 0).astype(np.float32) |
| sample_id = record.issue_ts.strftime("%Y%m%d_%H") |
| sample_path = ctx.sample_dir / f"phase1_regional_{sample_id}.npz" |
| np.savez_compressed( |
| sample_path, |
| weather=weather[i], |
| firewx=empty_firewx, |
| firewx_valid=firewx_valid, |
| y_count=y_count[None, ...], |
| y_occ=y_occ[None, ...], |
| lat=y_desc, |
| lon=x_asc, |
| coord_crs=np.array([target_crs], dtype=object), |
| weather_names=np.array(weather_names, dtype=object), |
| firewx_names=np.array([], dtype=object), |
| ) |
| split = split_for_timestamp(record.issue_ts, ctx.config) |
| rows.append( |
| { |
| "sample_id": sample_id, |
| "input_date": str(record.issue_ts.date()), |
| "target_date": str(record.target_ts.date()), |
| "input_timestamp": record.issue_ts.isoformat(), |
| "target_timestamp": record.target_ts.isoformat(), |
| "split": split, |
| "sample_path": str(sample_path), |
| "static_path": str(static_path), |
| "pos_cells": int(y_occ.sum()), |
| "fire_points": float(y_count.sum()), |
| } |
| ) |
| if (i + 1) % 20 == 0 or (i + 1) == len(records): |
| print( |
| json.dumps( |
| { |
| "stage": "write_cache_progress", |
| "written_samples": i + 1, |
| "total_samples": len(records), |
| "latest_sample_id": sample_id, |
| } |
| ), |
| flush=True, |
| ) |
|
|
| manifest = pd.DataFrame(rows).sort_values("input_timestamp").reset_index(drop=True) |
| manifest.to_csv(ctx.manifest_dir / "phase1_manifest.csv", index=False) |
| for split in ["train", "val", "test"]: |
| manifest[manifest["split"] == split].copy().to_csv(ctx.split_dir / f"{split}.csv", index=False) |
|
|
| summary = { |
| "num_samples": int(len(manifest)), |
| "weather_shape": list(weather.shape), |
| "firewx_shape": [len(records), 0, h, w], |
| "firewx_valid_shape": [len(records), 1, h, w], |
| "static_shape": list(static.shape), |
| "static_valid_shape": list(static_valid.shape), |
| "splits": {k: int((manifest["split"] == k).sum()) for k in ["train", "val", "test"]}, |
| "total_positive_cells": int(manifest["pos_cells"].sum()), |
| "total_fire_points": float(manifest["fire_points"].sum()), |
| "grid_height": h, |
| "grid_width": w, |
| "target_crs": str(ctx.config["target_crs"]), |
| "target_resolution_m": int(ctx.config["target_resolution_m"]), |
| "target_offset_hours": int(ctx.config["target_offset_hours"]), |
| "weather_names": weather_names, |
| "static_names": static_names, |
| } |
| (ctx.manifest_dir / "phase1_cache_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") |
| return manifest |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--config", type=Path, required=True) |
| args = parser.parse_args() |
|
|
| config = load_json(args.config) |
| ctx = build_context(config) |
| records = build_sample_records(ctx) |
| y_desc, x_asc = build_projected_grid(ctx.config, str(ctx.config["target_crs"]), float(ctx.config["target_resolution_m"])) |
|
|
| print( |
| f"[stage] regional_samples={len(records)} first={records[0].issue_ts.isoformat()} last={records[-1].issue_ts.isoformat()}", |
| flush=True, |
| ) |
| print( |
| f"[stage] grid_crs={ctx.config['target_crs']} resolution_m={ctx.config['target_resolution_m']} " |
| f"shape=({len(y_desc)}, {len(x_asc)})", |
| flush=True, |
| ) |
| print("[stage] load_hrrr_cube", flush=True) |
| weather, weather_names = load_hrrr_cube(ctx, records, y_desc, x_asc) |
| print("[stage] load_static_cube", flush=True) |
| static, static_names, static_valid = load_static_cube_projected(ctx, y_desc, x_asc) |
| print("[stage] write_cache", flush=True) |
| manifest = write_cache( |
| ctx=ctx, |
| records=records, |
| weather=weather, |
| static=static, |
| static_valid=static_valid, |
| y_desc=y_desc, |
| x_asc=x_asc, |
| weather_names=weather_names, |
| static_names=static_names, |
| ) |
| print("[stage] done", flush=True) |
| print(f"Built regional HRRR cache with {len(manifest)} samples.") |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|