From 8b2fd8860f60b318b536f05d001c9a24d887fd57 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Wed, 17 Jun 2026 17:45:56 -0400 Subject: [PATCH 01/14] initial script --- era5_download/era5/config.yml | 80 + era5_download/era5/download_era5.py | 1345 +++++++++++++++++ .../modis/download_modis.py | 0 .../modis/index_era5.py | 0 {scripts => era5_download}/modis/pipeline.py | 8 +- {scripts => era5_download}/modis/pipeline.sh | 10 +- .../modis/pipeline_config.yml | 0 .../modis/plot_igbp_map.py | 0 .../modis/rebuild_ids_era5.py | 0 .../modis/transform_modis.py | 2 +- .../modis/update_igbp_from_c1.py | 0 11 files changed, 1435 insertions(+), 10 deletions(-) create mode 100644 era5_download/era5/config.yml create mode 100644 era5_download/era5/download_era5.py rename {scripts => era5_download}/modis/download_modis.py (100%) rename {scripts => era5_download}/modis/index_era5.py (100%) rename {scripts => era5_download}/modis/pipeline.py (98%) rename {scripts => era5_download}/modis/pipeline.sh (53%) rename {scripts => era5_download}/modis/pipeline_config.yml (100%) rename {scripts => era5_download}/modis/plot_igbp_map.py (100%) rename {scripts => era5_download}/modis/rebuild_ids_era5.py (100%) rename {scripts => era5_download}/modis/transform_modis.py (99%) rename {scripts => era5_download}/modis/update_igbp_from_c1.py (100%) diff --git a/era5_download/era5/config.yml b/era5_download/era5/config.yml new file mode 100644 index 0000000..dd8402f --- /dev/null +++ b/era5_download/era5/config.yml @@ -0,0 +1,80 @@ +years: + - 2016 + - 2017 + +paths: + output_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017 + db_path: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_2016_2017.db + netcdf_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_data + zip_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_zip + +download: + enabled: true + dataset: reanalysis-era5-single-levels + product_type: reanalysis + data_format: netcdf + download_format: zip + # ERA5/CDS area order is north, west, south, east. + bbox: [90, -180, -90, 180] + # CarbonCast forces daily groups for large areas. Latitude bands keep each + # request smaller for global exports. + temporal_chunk: daily + latitude_band_degrees: 30 + overwrite: false + variables: + - 10m_u_component_of_wind + - 10m_v_component_of_wind + - 2m_dewpoint_temperature + - 2m_temperature + - surface_pressure + - total_precipitation + - mean_surface_downward_long_wave_radiation_flux + - mean_surface_downward_short_wave_radiation_flux + - mean_surface_downward_short_wave_radiation_flux_clear_sky + - mean_surface_net_long_wave_radiation_flux + - mean_surface_latent_heat_flux + - mean_surface_sensible_heat_flux + - soil_temperature_level_1 + - soil_temperature_level_2 + - soil_temperature_level_3 + - volumetric_soil_water_layer_1 + - volumetric_soil_water_layer_2 + - volumetric_soil_water_layer_3 + - forecast_albedo + - friction_velocity + - geopotential + +processing: + recreate_db: true + batch_size: 50000 + xarray_engine: h5netcdf + create_index: true + sqlite_temp_dir: null + sqlite_threads: 8 + + timezone: + enabled: true + # "local" stores wall-clock timestamps per coordinate. Half-hour offsets + # produce timestamps such as YYYYMMDDHH3000. + timestamp_policy: local + # Use timezonefinder/IANA zones when installed. If a coordinate has no + # zone, fall back to longitude-based quarter-hour offsets. + method: timezonefinder + require_timezonefinder: true + fallback: longitude_quarter_hour + local_window: + enabled: true + start: "2016-01-01 00:00:00" + end: "2017-12-31 23:59:59" + + land_sea_mask: + enabled: true + path: experiments/data/lsm.nc + threshold: 0.5 + include_land_neighbors: true + allow_missing: false + + igbp: + enabled: true + raster_path: experiments/data/raw_modis/201701011200C1.tiff + allow_missing: false diff --git a/era5_download/era5/download_era5.py b/era5_download/era5/download_era5.py new file mode 100644 index 0000000..f167384 --- /dev/null +++ b/era5_download/era5/download_era5.py @@ -0,0 +1,1345 @@ +#!/usr/bin/env python3 +"""Download ERA5 and export EcoPerceiver-compatible SQLite. + +The download layout follows CarbonCast's ERA5/CDS request model: request +ERA5 single-level variables, unzip NetCDF chunks, convert ERA5 short names +to predictor variables, then write ``coord_data`` and ``ec_data`` tables. + +The post-processing deliberately computes local time with minute-precision +UTC offsets. This avoids the India/Australia bug caused by assuming every +time-zone offset is a whole number of hours. +""" + +from __future__ import annotations + +import argparse +import calendar +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import json +import math +import os +from pathlib import Path +import shutil +import sqlite3 +import sys +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +import zipfile + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_CONFIG_PATH = SCRIPT_DIR / "config.yml" +DEFAULT_DB_FILENAME = "era5_2016_2017.db" + +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +MISSING_DEPENDENCIES: dict[str, str] = {} + +try: + import yaml +except ModuleNotFoundError: + MISSING_DEPENDENCIES["yaml"] = "PyYAML" + yaml = None + +try: + import pandas as pd +except ModuleNotFoundError: + MISSING_DEPENDENCIES["pandas"] = "pandas" + pd = None + +try: + import xarray as xr +except ModuleNotFoundError: + MISSING_DEPENDENCIES["xarray"] = "xarray" + xr = None + +try: + import cdsapi +except ModuleNotFoundError: + MISSING_DEPENDENCIES["cdsapi"] = "cdsapi" + cdsapi = None + +try: + import rasterio +except ModuleNotFoundError: + MISSING_DEPENDENCIES["rasterio"] = "rasterio" + rasterio = None + +try: + from timezonefinder import TimezoneFinder +except ModuleNotFoundError: + MISSING_DEPENDENCIES["timezonefinder"] = "timezonefinder" + TimezoneFinder = None + +try: + from tqdm.auto import tqdm +except ModuleNotFoundError: + tqdm = None + +from ecoperceiver.constants import DEFAULT_NORM, EC_PREDICTORS + +try: + from ecoperceiver.constants import IGBP_ACRONYMS_MODIS +except ImportError: + IGBP_ACRONYMS_MODIS = { + 0: "WAT", + 1: "ENF", + 2: "EBF", + 3: "DNF", + 4: "DBF", + 5: "MF", + 6: "CSH", + 7: "OSH", + 8: "WSA", + 9: "SAV", + 10: "GRA", + 11: "WET", + 12: "CRO", + 13: "URB", + 14: "CVM", + 15: "SNO", + 16: "BSV", + } + + +ERA5_VARIABLES = [ + "10m_u_component_of_wind", + "10m_v_component_of_wind", + "2m_dewpoint_temperature", + "2m_temperature", + "surface_pressure", + "total_precipitation", + "mean_surface_downward_long_wave_radiation_flux", + "mean_surface_downward_short_wave_radiation_flux", + "mean_surface_downward_short_wave_radiation_flux_clear_sky", + "mean_surface_net_long_wave_radiation_flux", + "mean_surface_latent_heat_flux", + "mean_surface_sensible_heat_flux", + "soil_temperature_level_1", + "soil_temperature_level_2", + "soil_temperature_level_3", + "volumetric_soil_water_layer_1", + "volumetric_soil_water_layer_2", + "volumetric_soil_water_layer_3", + "forecast_albedo", + "friction_velocity", + "geopotential", +] + +SHORTNAME_TO_FULLNAME = { + "u10": "10m_u_component_of_wind", + "v10": "10m_v_component_of_wind", + "t2m": "2m_temperature", + "d2m": "2m_dewpoint_temperature", + "sp": "surface_pressure", + "tp": "total_precipitation", + "avg_sdlwrf": "mean_surface_downward_long_wave_radiation_flux", + "avg_sdswrf": "mean_surface_downward_short_wave_radiation_flux", + "avg_sdswrfcs": "mean_surface_downward_short_wave_radiation_flux_clear_sky", + "avg_snlwrf": "mean_surface_net_long_wave_radiation_flux", + "avg_slhtf": "mean_surface_latent_heat_flux", + "avg_ishf": "mean_surface_sensible_heat_flux", + "stl1": "soil_temperature_level_1", + "stl2": "soil_temperature_level_2", + "stl3": "soil_temperature_level_3", + "swvl1": "volumetric_soil_water_layer_1", + "swvl2": "volumetric_soil_water_layer_2", + "swvl3": "volumetric_soil_water_layer_3", + "fal": "forecast_albedo", + "zust": "friction_velocity", + "z": "geopotential", + # CDS/GRIB aliases observed in ERA5 NetCDF exports. + "msdwlwrf": "mean_surface_downward_long_wave_radiation_flux", + "msdwswrf": "mean_surface_downward_short_wave_radiation_flux", + "msdwswrfcs": "mean_surface_downward_short_wave_radiation_flux_clear_sky", + "msnlwrf": "mean_surface_net_long_wave_radiation_flux", + "mslhf": "mean_surface_latent_heat_flux", + "msshf": "mean_surface_sensible_heat_flux", +} + +VARIABLES_FOR_PREDICTOR = { + "TA": ["2m_temperature"], + "P": ["total_precipitation"], + "RH": ["2m_temperature", "2m_dewpoint_temperature"], + "VPD": ["2m_temperature", "2m_dewpoint_temperature"], + "PA": ["surface_pressure"], + "CO2": ["2m_temperature", "2m_dewpoint_temperature", "surface_pressure", "xco2"], + "SW_IN": ["mean_surface_downward_short_wave_radiation_flux"], + "SW_IN_POT": ["mean_surface_downward_short_wave_radiation_flux_clear_sky"], + "SW_OUT": ["mean_surface_downward_short_wave_radiation_flux", "forecast_albedo"], + "LW_IN": ["mean_surface_downward_long_wave_radiation_flux"], + "LW_OUT": [ + "mean_surface_downward_long_wave_radiation_flux", + "mean_surface_net_long_wave_radiation_flux", + ], + "NETRAD": [ + "mean_surface_downward_short_wave_radiation_flux", + "mean_surface_downward_long_wave_radiation_flux", + "mean_surface_net_long_wave_radiation_flux", + "forecast_albedo", + ], + "WS": ["10m_u_component_of_wind", "10m_v_component_of_wind"], + "WD": ["10m_u_component_of_wind", "10m_v_component_of_wind"], + "USTAR": ["friction_velocity"], + "SWC_1": ["volumetric_soil_water_layer_1"], + "SWC_2": ["volumetric_soil_water_layer_1"], + "SWC_3": ["volumetric_soil_water_layer_2"], + "SWC_4": ["volumetric_soil_water_layer_2"], + "SWC_5": ["volumetric_soil_water_layer_3"], + "TS_1": ["soil_temperature_level_1"], + "TS_2": ["soil_temperature_level_1"], + "TS_3": ["soil_temperature_level_2"], + "TS_4": ["soil_temperature_level_2"], + "TS_5": ["soil_temperature_level_3"], + "G": [ + "mean_surface_sensible_heat_flux", + "mean_surface_latent_heat_flux", + "mean_surface_downward_short_wave_radiation_flux", + "mean_surface_downward_long_wave_radiation_flux", + "mean_surface_net_long_wave_radiation_flux", + "forecast_albedo", + ], + "H": ["mean_surface_sensible_heat_flux"], + "LE": ["mean_surface_latent_heat_flux"], + "PPFD_IN": ["mean_surface_downward_short_wave_radiation_flux"], + "PPFD_OUT": ["mean_surface_downward_short_wave_radiation_flux", "forecast_albedo"], + "WTD": ["wtd"], + "ELEVATION": ["geopotential"], +} + +GRAVITATIONAL_ACC = 9.8 +ZERO_C_IN_K = 273.15 +DRY_AIR_MOLE_FRACTION_N2 = 0.7808 +DRY_AIR_MOLE_FRACTION_O2 = 0.2095 +DRY_AIR_MOLE_FRACTION_AR = 0.0093 + + +def kelvin_to_celsius(t_k): + return t_k - ZERO_C_IN_K + + +def pa_to_kpa(p_pa): + return p_pa / 1000.0 + + +def kpa_to_pa(p_kpa): + return p_kpa * 1000.0 + + +def kpa_to_hpa(p_kpa): + return p_kpa * 10.0 + + +def wind_speed_magnitude(u10, v10): + return np.hypot(u10, v10) + + +def wind_speed_direction(u10, v10): + return (180.0 + (180.0 / np.pi) * np.arctan2(u10, v10)) % 360.0 + + +def saturated_vapor_pressure(t2m_c): + a = np.where(t2m_c >= 0, 17.27, 21.875) + b = np.where(t2m_c >= 0, 237.3, 265.5) + return 0.61078 * np.exp(a * t2m_c / (t2m_c + b)) + + +def relative_humidity(t2m, d2m): + t_air_c = kelvin_to_celsius(t2m) + t_dew_c = kelvin_to_celsius(d2m) + a, b = 17.625, 243.04 + gamma_air = (a * t_air_c) / (b + t_air_c) + gamma_dew = (a * t_dew_c) / (b + t_dew_c) + return 100.0 * np.exp(gamma_dew - gamma_air) + + +def vapor_pressure_deficit(t2m, d2m): + rh = relative_humidity(t2m, d2m) + es_kpa = saturated_vapor_pressure(kelvin_to_celsius(t2m)) + vpd_kpa = es_kpa * (1.0 - (rh / 100.0)) + return kpa_to_hpa(vpd_kpa) + + +def shortwave_out(avg_sdswrf, fal): + return avg_sdswrf * fal + + +def longwave_out(avg_sdlwrf, avg_snlwrf): + return avg_sdlwrf - avg_snlwrf + + +def net_radiation(avg_sdswrf, avg_sdlwrf, avg_snlwrf, fal): + return ( + avg_sdswrf + + avg_sdlwrf + - shortwave_out(avg_sdswrf, fal) + - longwave_out(avg_sdlwrf, avg_snlwrf) + ) + + +def dry_to_wet_co2_fraction(t2m, d2m, sp, xco2_dry): + rh = relative_humidity(t2m, d2m) + t_air_c = kelvin_to_celsius(t2m) + es_pa = kpa_to_pa(saturated_vapor_pressure(t_air_c)) + + xh2o_wet = (rh / 100.0) * es_pa / sp + xdry_wet = 1.0 - xh2o_wet + xh2o_dry = xh2o_wet / xdry_wet + + n_tot = ( + DRY_AIR_MOLE_FRACTION_N2 + + DRY_AIR_MOLE_FRACTION_O2 + + DRY_AIR_MOLE_FRACTION_AR + + xco2_dry / 1e6 + + xh2o_dry + ) + return xco2_dry / n_tot + + +def soil_heat_flux(avg_ishf, avg_slhtf, avg_sdswrf, avg_sdlwrf, avg_snlwrf, fal): + return net_radiation(avg_sdswrf, avg_sdlwrf, avg_snlwrf, fal) + avg_ishf + avg_slhtf + + +def photosynthesis_photo_flux_density(avg_sdswrf, fal=None): + if fal is None: + return 1.741 * avg_sdswrf + 1.45 + return 1.741 * avg_sdswrf * fal + 1.45 + + +PROCESSORS = { + "RH": relative_humidity, + "VPD": vapor_pressure_deficit, + "TA": kelvin_to_celsius, + "PA": pa_to_kpa, + "SW_OUT": shortwave_out, + "LW_OUT": longwave_out, + "NETRAD": net_radiation, + "WS": wind_speed_magnitude, + "WD": wind_speed_direction, + "G": soil_heat_flux, + "H": lambda x: -1.0 * x, + "LE": lambda x: -1.0 * x, + "TS_1": kelvin_to_celsius, + "TS_2": kelvin_to_celsius, + "TS_3": kelvin_to_celsius, + "TS_4": kelvin_to_celsius, + "TS_5": kelvin_to_celsius, + "SWC_1": lambda x: x * 100.0, + "SWC_2": lambda x: x * 100.0, + "SWC_3": lambda x: x * 100.0, + "SWC_4": lambda x: x * 100.0, + "SWC_5": lambda x: x * 100.0, + "PPFD_IN": photosynthesis_photo_flux_density, + "PPFD_OUT": photosynthesis_photo_flux_density, + "CO2": dry_to_wet_co2_fraction, + "WTD": lambda x: x, + "ELEVATION": lambda x: x / GRAVITATIONAL_ACC, +} + + +@dataclass(frozen=True) +class RequestGroup: + year: str + month: str + days: list[str] + hours: list[str] + area: list[float] + + @property + def stem(self) -> str: + north, west, south, east = self.area + day_token = ( + self.days[0] + if len(self.days) == 1 + else f"{self.days[0]}to{self.days[-1]}" + ) + hour_token = ( + "all-hours" + if len(self.hours) == 24 + else f"{self.hours[0].replace(':', '')}to{self.hours[-1].replace(':', '')}" + ) + area_token = ( + f"N{north:g}_W{west:g}_S{south:g}_E{east:g}" + .replace("-", "m") + .replace(".", "p") + ) + return f"ERA5_{self.year}-{self.month}-{day_token}_{hour_token}_{area_token}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Download ERA5 and build an EcoPerceiver SQLite database." + ) + parser.add_argument( + "--config", + type=Path, + default=DEFAULT_CONFIG_PATH, + help=f"ERA5 YAML config. Default: {DEFAULT_CONFIG_PATH}", + ) + parser.add_argument( + "--download-only", + action="store_true", + help="Run only the CDS download stage.", + ) + parser.add_argument( + "--process-only", + action="store_true", + help="Run only NetCDF to SQLite post-processing.", + ) + parser.add_argument( + "--overwrite-downloads", + action="store_true", + help="Overwrite downloaded ZIPs and extracted NetCDF chunk directories.", + ) + parser.add_argument( + "--overwrite-db", + action="store_true", + help="Recreate the SQLite database even if the config disables recreation.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned requests without downloading or writing the database.", + ) + parser.add_argument( + "--limit-groups", + type=int, + default=None, + help="Limit request/group count for smoke tests.", + ) + args = parser.parse_args() + + if args.download_only and args.process_only: + parser.error("--download-only and --process-only are mutually exclusive.") + return args + + +def ensure_dependencies(*module_names: str) -> None: + missing = [ + MISSING_DEPENDENCIES[name] + for name in module_names + if name in MISSING_DEPENDENCIES + ] + if missing: + raise SystemExit( + "Missing Python dependencies: " + f"{', '.join(sorted(set(missing)))}. Install them in the project " + "environment before running this stage." + ) + + +def load_config(path: Path) -> dict[str, Any]: + ensure_dependencies("yaml") + resolved = path.expanduser().resolve() + if not resolved.exists(): + raise SystemExit(f"Config file does not exist: {resolved}") + with resolved.open("r", encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + if not isinstance(config, dict): + raise SystemExit(f"Config must be a YAML mapping: {resolved}") + return config + + +def section(config: dict[str, Any], name: str) -> dict[str, Any]: + value = config.get(name, {}) or {} + if not isinstance(value, dict): + raise SystemExit(f"Config section {name!r} must be a mapping.") + return value + + +def resolve_path(value: str | os.PathLike | None, *, default: Path | None = None) -> Path | None: + if value is None: + return default + path = Path(value).expanduser() + if path.is_absolute(): + return path + return (REPO_ROOT / path).resolve() + + +def years_to_range(years: Iterable[int | str]) -> tuple[datetime, datetime]: + year_values = sorted(int(year) for year in years) + if not year_values: + raise SystemExit("Config field 'years' must contain at least one year.") + start = datetime(year_values[0], 1, 1, 0, 0, 0) + end = datetime(year_values[-1], 12, 31, 23, 0, 0) + return start, end + + +def configured_time_range(config: dict[str, Any]) -> tuple[datetime, datetime]: + download_config = section(config, "download") + if download_config.get("start") and download_config.get("end"): + return ( + datetime.fromisoformat(str(download_config["start"])), + datetime.fromisoformat(str(download_config["end"])), + ) + return years_to_range(config.get("years", [])) + + +def full_hours() -> list[str]: + return [f"{hour:02d}:00" for hour in range(24)] + + +def month_days(year: int, month: int) -> list[str]: + days = calendar.monthrange(year, month)[1] + return [f"{day:02d}" for day in range(1, days + 1)] + + +def iter_month_starts(start: datetime, end: datetime) -> Iterable[datetime]: + current = datetime(start.year, start.month, 1) + final = datetime(end.year, end.month, 1) + while current <= final: + yield current + if current.month == 12: + current = datetime(current.year + 1, 1, 1) + else: + current = datetime(current.year, current.month + 1, 1) + + +def iter_day_starts(start: datetime, end: datetime) -> Iterable[datetime]: + current = datetime(start.year, start.month, start.day) + final = datetime(end.year, end.month, end.day) + while current <= final: + yield current + current += timedelta(days=1) + + +def split_bbox_latitude_bands( + bbox: list[float], + latitude_band_degrees: float | int | None, +) -> list[list[float]]: + north, west, south, east = [float(value) for value in bbox] + if latitude_band_degrees is None or float(latitude_band_degrees) <= 0: + return [[north, west, south, east]] + + bands = [] + band_north = north + step = float(latitude_band_degrees) + while band_north > south: + band_south = max(south, band_north - step) + bands.append([band_north, west, band_south, east]) + band_north = band_south + return bands + + +def build_request_groups(config: dict[str, Any]) -> list[RequestGroup]: + start, end = configured_time_range(config) + if end < start: + raise SystemExit(f"Download end date is before start date: {start} > {end}") + + download_config = section(config, "download") + bbox = download_config.get("bbox", [90, -180, -90, 180]) + if not isinstance(bbox, list) or len(bbox) != 4: + raise SystemExit("download.bbox must be [north, west, south, east].") + areas = split_bbox_latitude_bands( + bbox, + download_config.get("latitude_band_degrees"), + ) + chunk = str(download_config.get("temporal_chunk", "daily")).lower() + hours = full_hours() + + groups: list[RequestGroup] = [] + for month_start in iter_month_starts(start, end): + month_end = datetime( + month_start.year, + month_start.month, + calendar.monthrange(month_start.year, month_start.month)[1], + 23, + 0, + 0, + ) + active_start = max(start, month_start) + active_end = min(end, month_end) + if active_start > active_end: + continue + + if chunk == "monthly" and active_start == month_start and active_end >= month_end: + days = month_days(month_start.year, month_start.month) + for area in areas: + groups.append( + RequestGroup( + str(month_start.year), + f"{month_start.month:02d}", + days, + hours, + area, + ) + ) + continue + + if chunk not in {"daily", "monthly"}: + raise SystemExit("download.temporal_chunk must be 'daily' or 'monthly'.") + + for day in iter_day_starts(active_start, active_end): + day_start = datetime(day.year, day.month, day.day, 0, 0, 0) + day_end = datetime(day.year, day.month, day.day, 23, 0, 0) + hour_start = max(active_start, day_start).hour + hour_end = min(active_end, day_end).hour + day_hours = [f"{hour:02d}:00" for hour in range(hour_start, hour_end + 1)] + for area in areas: + groups.append( + RequestGroup( + str(day.year), + f"{day.month:02d}", + [f"{day.day:02d}"], + day_hours, + area, + ) + ) + return groups + + +def cds_request_payload(config: dict[str, Any], group: RequestGroup) -> dict[str, Any]: + download_config = section(config, "download") + product_type = download_config.get("product_type", "reanalysis") + variables = download_config.get("variables") or ERA5_VARIABLES + return { + "product_type": [product_type], + "variable": variables, + "year": [group.year], + "month": [group.month], + "day": group.days, + "time": group.hours, + "area": group.area, + "data_format": download_config.get("data_format", "netcdf"), + "download_format": download_config.get("download_format", "zip"), + } + + +def extract_zip(zip_path: Path, output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(zip_path, "r") as archive: + archive.extractall(output_dir) + + +def download_groups(config: dict[str, Any], args: argparse.Namespace) -> None: + paths = section(config, "paths") + download_config = section(config, "download") + zip_dir = resolve_path(paths.get("zip_dir"), default=REPO_ROOT / "experiments/data/era5_zip") + netcdf_dir = resolve_path( + paths.get("netcdf_dir"), + default=REPO_ROOT / "experiments/data/era5_data", + ) + assert zip_dir is not None and netcdf_dir is not None + + groups = build_request_groups(config) + if args.limit_groups is not None: + groups = groups[: args.limit_groups] + overwrite = bool(download_config.get("overwrite", False) or args.overwrite_downloads) + + print(f"Planned ERA5 CDS request groups: {len(groups)}") + for index, group in enumerate(groups[:5], start=1): + print(f" {index:>3}: {group.stem} area={group.area}") + if len(groups) > 5: + print(f" ... {len(groups) - 5} more") + if args.dry_run: + return + + ensure_dependencies("cdsapi") + zip_dir.mkdir(parents=True, exist_ok=True) + netcdf_dir.mkdir(parents=True, exist_ok=True) + client = cdsapi.Client(wait_until_complete=True, delete=False) + dataset = download_config.get("dataset", "reanalysis-era5-single-levels") + progress = ( + tqdm( + total=len(groups), + desc="ERA5 downloads", + unit="group", + dynamic_ncols=True, + ) + if tqdm is not None + else None + ) + completed = 0 + skipped = 0 + + def log(message: str) -> None: + if progress is not None: + progress.write(message) + else: + print(message) + + try: + for group in groups: + if progress is not None: + progress.set_postfix_str(group.stem, refresh=False) + + group_dir = netcdf_dir / group.stem + sentinel = group_dir / ".complete" + zip_path = zip_dir / f"{group.stem}.zip" + if sentinel.exists() and not overwrite: + skipped += 1 + log(f"[skip] {group.stem}") + if progress is not None: + progress.update(1) + progress.set_postfix(done=completed, skipped=skipped, refresh=False) + continue + if overwrite and group_dir.exists(): + shutil.rmtree(group_dir) + if overwrite and zip_path.exists(): + zip_path.unlink() + + payload = cds_request_payload(config, group) + log(f"[request] {group.stem}") + result = client.retrieve(dataset, payload) + log(f"[download] {zip_path}") + result.download(str(zip_path)) + log(f"[extract] {group_dir}") + extract_zip(zip_path, group_dir) + sentinel.write_text("complete\n", encoding="utf-8") + zip_path.unlink(missing_ok=True) + completed += 1 + if progress is not None: + progress.update(1) + progress.set_postfix(done=completed, skipped=skipped, refresh=False) + finally: + if progress is not None: + progress.close() + + +def quote_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def init_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: + conn.executescript( + f""" + CREATE TABLE IF NOT EXISTS coord_data ( + coord_id INTEGER PRIMARY KEY, + lat REAL NOT NULL, + lon REAL NOT NULL, + elev REAL, + igbp TEXT, + UNIQUE(lat, lon) + ); + + CREATE TABLE IF NOT EXISTS ec_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + coord_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + {", ".join(f"{quote_identifier(column)} REAL" for column in EC_PREDICTORS)}, + FOREIGN KEY(coord_id) REFERENCES coord_data(coord_id) + ); + + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT + ); + """ + ) + metadata = { + "generator": "era5_download/era5/download_era5.py", + "config_metadata": section(config, "metadata"), + "years": config.get("years"), + "created_utc": datetime.now(timezone.utc).isoformat(), + } + conn.executemany( + """ + INSERT INTO metadata(key, value) + VALUES(?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + [(key, json.dumps(value, sort_keys=True)) for key, value in metadata.items()], + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_coord_data_lat_lon ON coord_data(lat, lon)") + + +def normalize_longitudes(values) -> np.ndarray: + arr = np.asarray(values, dtype=float) + normalized = ((arr + 180.0) % 360.0) - 180.0 + normalized[np.isclose(normalized, -180.0)] = 180.0 + return normalized + + +def rounded_coord_key(lat: float, lon: float) -> tuple[float, float]: + return (round(float(lat), 6), round(float(lon), 6)) + + +class TimezoneResolver: + def __init__(self, config: dict[str, Any]): + timezone_config = section(section(config, "processing"), "timezone") + self.enabled = bool(timezone_config.get("enabled", True)) + self.timestamp_policy = str(timezone_config.get("timestamp_policy", "local")).lower() + self.method = str(timezone_config.get("method", "timezonefinder")).lower() + self.fallback = str(timezone_config.get("fallback", "longitude_quarter_hour")).lower() + self.require_timezonefinder = bool(timezone_config.get("require_timezonefinder", True)) + self.zone_cache: dict[tuple[float, float], str | None] = {} + self.offset_cache: dict[tuple[str | None, datetime, float], int] = {} + self.timezone_finder = None + if self.enabled and self.method == "timezonefinder": + if TimezoneFinder is None: + if self.require_timezonefinder: + ensure_dependencies("timezonefinder") + else: + print( + "timezonefinder is not installed; using longitude-quarter-hour fallback.", + file=sys.stderr, + ) + else: + self.timezone_finder = TimezoneFinder() + + def localize_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> "pd.DataFrame": + if not self.enabled: + return self._assign_utc_time(df, utc_timestamp) + + utc_ts = pd.Timestamp(utc_timestamp) + if utc_ts.tzinfo is None: + utc_ts = utc_ts.tz_localize("UTC") + else: + utc_ts = utc_ts.tz_convert("UTC") + utc_dt = utc_ts.to_pydatetime() + + coord_offsets = self.offset_minutes_for_coordinates( + df[["lat", "lon"]].drop_duplicates(), + utc_dt, + ) + keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) + offset_minutes = keys.map(coord_offsets).to_numpy(dtype=np.int32) + + utc_naive = utc_ts.tz_localize(None) + local_datetimes = utc_naive + pd.to_timedelta(offset_minutes, unit="m") + if isinstance(local_datetimes, pd.Timestamp): + local_series = pd.Series([local_datetimes] * len(df), index=df.index) + else: + local_series = pd.Series(local_datetimes, index=df.index) + + df["_local_timestamp"] = datetimes_to_int(local_series) + if self.timestamp_policy == "local": + df["timestamp"] = df["_local_timestamp"] + else: + df["timestamp"] = datetimes_to_int(pd.Series(utc_naive, index=df.index)) + df["DOY"] = local_series.dt.dayofyear.astype(float) + tod = ( + local_series.dt.hour.astype(float) + + local_series.dt.minute.astype(float) / 60.0 + + local_series.dt.second.astype(float) / 3600.0 + + 1.0 + ) + df["TOD"] = tod.where(tod <= 24.0, tod - 24.0) + return df + + def _assign_utc_time(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> "pd.DataFrame": + utc_ts = pd.Timestamp(utc_timestamp) + if utc_ts.tzinfo is not None: + utc_ts = utc_ts.tz_convert("UTC").tz_localize(None) + df["timestamp"] = int(utc_ts.strftime("%Y%m%d%H%M%S")) + df["_local_timestamp"] = df["timestamp"] + df["DOY"] = float(utc_ts.dayofyear) + df["TOD"] = float(utc_ts.hour + 1) + return df + + def offset_minutes_for_coordinates( + self, + coords: "pd.DataFrame", + utc_dt: datetime, + ) -> dict[tuple[float, float], int]: + offsets: dict[tuple[float, float], int] = {} + for row in coords.itertuples(index=False): + lat = float(row.lat) + lon = float(row.lon) + key = rounded_coord_key(lat, lon) + zone_name = self.zone_for_coordinate(lat, lon) + cache_key = (zone_name, utc_dt.replace(tzinfo=timezone.utc), round(lon, 6)) + if cache_key not in self.offset_cache: + self.offset_cache[cache_key] = self.offset_for_zone(zone_name, lon, utc_dt) + offsets[key] = self.offset_cache[cache_key] + return offsets + + def zone_for_coordinate(self, lat: float, lon: float) -> str | None: + key = rounded_coord_key(lat, lon) + if key in self.zone_cache: + return self.zone_cache[key] + zone_name = None + if self.timezone_finder is not None: + zone_name = self.timezone_finder.timezone_at(lng=lon, lat=lat) + if zone_name is None: + zone_name = self.timezone_finder.closest_timezone_at(lng=lon, lat=lat) + self.zone_cache[key] = zone_name + return zone_name + + def offset_for_zone(self, zone_name: str | None, lon: float, utc_dt: datetime) -> int: + if zone_name: + try: + local_dt = utc_dt.astimezone(ZoneInfo(zone_name)) + offset = local_dt.utcoffset() + if offset is not None: + return int(offset.total_seconds() // 60) + except ZoneInfoNotFoundError: + pass + if self.fallback in {"longitude_quarter_hour", "longitude"}: + minutes = lon * 4.0 + if self.fallback == "longitude_quarter_hour": + minutes = round(minutes / 15.0) * 15.0 + return int(max(-12 * 60, min(14 * 60, minutes))) + return 0 + + +def datetimes_to_int(values: "pd.Series") -> "pd.Series": + return values.dt.strftime("%Y%m%d%H%M%S").astype("int64") + + +class LandSeaMask: + def __init__(self, config: dict[str, Any]): + mask_config = section(section(config, "processing"), "land_sea_mask") + self.enabled = bool(mask_config.get("enabled", False)) + self.threshold = float(mask_config.get("threshold", 0.5)) + self.include_land_neighbors = bool(mask_config.get("include_land_neighbors", True)) + self.cache: dict[tuple[float, float], bool] = {} + self.da = None + path = resolve_path(mask_config.get("path")) + if not self.enabled: + return + if path is None or not path.exists(): + if mask_config.get("allow_missing", False): + print(f"Land-sea mask not found; skipping mask: {path}", file=sys.stderr) + self.enabled = False + return + raise SystemExit(f"Land-sea mask file does not exist: {path}") + ds = xr.open_dataset(path, engine=mask_config.get("xarray_engine")) + variable = mask_config.get("variable") + if variable is None: + variable = "lsm" if "lsm" in ds.data_vars else next(iter(ds.data_vars)) + self.da = ds[variable] + if "time" in self.da.dims: + self.da = self.da.isel(time=0) + + def filter(self, df: "pd.DataFrame") -> "pd.DataFrame": + if not self.enabled or self.da is None or df.empty: + return df + coords = df[["lat", "lon"]].drop_duplicates() + missing = [ + rounded_coord_key(row.lat, row.lon) + for row in coords.itertuples(index=False) + if rounded_coord_key(row.lat, row.lon) not in self.cache + ] + if missing: + self.populate_cache(missing) + keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) + keep = keys.map(self.cache).fillna(False).to_numpy(dtype=bool) + return df.loc[keep].copy() + + def populate_cache(self, keys: list[tuple[float, float]]) -> None: + lats = np.array(sorted({lat for lat, _ in keys}), dtype=float) + lons = np.array(sorted({lon for _, lon in keys}), dtype=float) + mask_lons = self.to_mask_longitudes(lons) + interp = self.da.interp(latitude=lats, longitude=mask_lons, method="nearest") + interp = interp.transpose("latitude", "longitude") + values = np.asarray(interp.values) + if values.ndim > 2: + values = np.squeeze(values) + land = np.nan_to_num(values, nan=0.0) > self.threshold + keep = land + if self.include_land_neighbors: + keep = land | neighbor_any(land) + for lat_index, lat in enumerate(lats): + for lon_index, lon in enumerate(lons): + self.cache[rounded_coord_key(lat, lon)] = bool(keep[lat_index, lon_index]) + + def to_mask_longitudes(self, lons: np.ndarray) -> np.ndarray: + mask_lons = np.asarray(self.da["longitude"].values, dtype=float) + if np.nanmin(mask_lons) >= 0 and np.nanmax(mask_lons) > 180: + return (lons + 360.0) % 360.0 + return lons + + +def neighbor_any(land: np.ndarray) -> np.ndarray: + padded = np.pad(land.astype(bool), 1, mode="constant", constant_values=False) + neighbors = np.zeros_like(land, dtype=bool) + for y_shift in range(3): + for x_shift in range(3): + if y_shift == 1 and x_shift == 1: + continue + neighbors |= padded[y_shift : y_shift + land.shape[0], x_shift : x_shift + land.shape[1]] + return neighbors + + +class IGBPSampler: + def __init__(self, config: dict[str, Any]): + igbp_config = section(section(config, "processing"), "igbp") + self.enabled = bool(igbp_config.get("enabled", False)) + self.cache: dict[tuple[float, float], str | None] = {} + self.src = None + path = resolve_path(igbp_config.get("raster_path")) + if not self.enabled: + return + if path is None or not path.exists(): + if igbp_config.get("allow_missing", False): + print(f"IGBP raster not found; writing NULL igbp values: {path}", file=sys.stderr) + self.enabled = False + return + raise SystemExit(f"IGBP raster does not exist: {path}") + if rasterio is None: + if igbp_config.get("allow_missing", False): + print("rasterio is not installed; writing NULL igbp values.", file=sys.stderr) + self.enabled = False + return + ensure_dependencies("rasterio") + self.src = rasterio.open(path) + + def assign(self, df: "pd.DataFrame") -> "pd.DataFrame": + if not self.enabled or self.src is None or df.empty: + df["igbp"] = None + return df + coords = df[["lat", "lon"]].drop_duplicates() + missing_rows = [ + row + for row in coords.itertuples(index=False) + if rounded_coord_key(row.lat, row.lon) not in self.cache + ] + if missing_rows: + points = [(float(row.lon), float(row.lat)) for row in missing_rows] + for row, sample in zip(missing_rows, self.src.sample(points)): + raw_value = sample[0] + key = rounded_coord_key(row.lat, row.lon) + try: + code = int(raw_value) + except (TypeError, ValueError): + self.cache[key] = None + else: + self.cache[key] = IGBP_ACRONYMS_MODIS.get(code) + keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) + df["igbp"] = keys.map(self.cache) + return df + + +class Era5DatabaseWriter: + def __init__(self, conn: sqlite3.Connection, batch_size: int): + self.conn = conn + self.batch_size = int(batch_size) + self.coord_lookup: dict[tuple[float, float], int] = {} + self.next_coord_id = 1 + self.load_existing_coords() + + def load_existing_coords(self) -> None: + rows = self.conn.execute("SELECT coord_id, lat, lon FROM coord_data").fetchall() + for coord_id, lat, lon in rows: + self.coord_lookup[rounded_coord_key(lat, lon)] = int(coord_id) + self.next_coord_id = max(self.next_coord_id, int(coord_id) + 1) + + def assign_coord_ids(self, df: "pd.DataFrame") -> "pd.DataFrame": + coord_rows = ( + df[["lat", "lon", "elev", "igbp"]] + .drop_duplicates(subset=["lat", "lon"]) + .reset_index(drop=True) + ) + inserts = [] + for row in coord_rows.itertuples(index=False): + key = rounded_coord_key(row.lat, row.lon) + if key in self.coord_lookup: + continue + coord_id = self.next_coord_id + self.next_coord_id += 1 + self.coord_lookup[key] = coord_id + inserts.append((coord_id, float(row.lat), float(row.lon), nullable_float(row.elev), row.igbp)) + if inserts: + self.conn.executemany( + """ + INSERT INTO coord_data(coord_id, lat, lon, elev, igbp) + VALUES(?, ?, ?, ?, ?) + ON CONFLICT(lat, lon) DO NOTHING + """, + inserts, + ) + keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) + df["coord_id"] = keys.map(self.coord_lookup).astype("int64") + return df + + def insert_ec_data(self, df: "pd.DataFrame") -> int: + for predictor in EC_PREDICTORS: + if predictor not in df.columns: + df[predictor] = np.nan + columns = ["coord_id", "timestamp", *EC_PREDICTORS] + df[columns].to_sql( + "ec_data", + self.conn, + if_exists="append", + index=False, + chunksize=self.batch_size, + ) + return len(df) + + +def nullable_float(value) -> float | None: + try: + value = float(value) + except (TypeError, ValueError): + return None + if math.isnan(value): + return None + return value + + +class Era5PostProcessor: + def __init__(self, config: dict[str, Any], conn: sqlite3.Connection): + processing_config = section(config, "processing") + self.config = config + self.conn = conn + self.batch_size = int(processing_config.get("batch_size", 50_000)) + self.xarray_engine = processing_config.get("xarray_engine") + self.timezone_resolver = TimezoneResolver(config) + self.land_mask = LandSeaMask(config) + self.igbp_sampler = IGBPSampler(config) + self.writer = Era5DatabaseWriter(conn, self.batch_size) + self.local_window = self.parse_local_window() + + def parse_local_window(self) -> tuple["pd.Timestamp | None", "pd.Timestamp | None"]: + timezone_config = section(section(self.config, "processing"), "timezone") + window_config = section(timezone_config, "local_window") + if not window_config.get("enabled", False): + return None, None + start = pd.Timestamp(window_config["start"]) if window_config.get("start") else None + end = pd.Timestamp(window_config["end"]) if window_config.get("end") else None + return start, end + + def process_groups(self, netcdf_dir: Path, limit_groups: int | None = None) -> int: + group_dirs = list(iter_netcdf_group_dirs(netcdf_dir)) + if limit_groups is not None: + group_dirs = group_dirs[:limit_groups] + if not group_dirs: + raise SystemExit(f"No NetCDF groups found under {netcdf_dir}") + + total_rows = 0 + print(f"NetCDF groups to process: {len(group_dirs)}") + for group_dir in group_dirs: + rows = self.process_group(group_dir) + total_rows += rows + print(f"[inserted] {rows:,} rows from {group_dir}") + return total_rows + + def process_group(self, group_dir: Path) -> int: + paths = sorted(group_dir.glob("*.nc")) + datasets = [open_dataset(path, self.xarray_engine) for path in paths] + if not datasets: + return 0 + try: + ds = xr.combine_by_coords(datasets, combine_attrs="override") if len(datasets) > 1 else datasets[0] + ds = standardize_dataset(ds) + if "valid_time" not in ds.coords: + raise RuntimeError(f"Dataset has no valid_time coordinate: {group_dir}") + inserted = 0 + valid_times = pd.to_datetime(ds["valid_time"].values) + for time_index, valid_time in enumerate(valid_times): + frame = ds.isel(valid_time=time_index).to_dataframe().reset_index() + inserted += self.process_frame(frame, pd.Timestamp(valid_time)) + self.conn.commit() + return inserted + finally: + for ds in datasets: + ds.close() + + def process_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> int: + df = standardize_dataframe(df) + if df.empty: + return 0 + df = self.land_mask.filter(df) + if df.empty: + return 0 + df = self.add_predictors(df) + df = self.igbp_sampler.assign(df) + df = self.timezone_resolver.localize_frame(df, utc_timestamp) + df = self.apply_local_window(df) + if df.empty: + return 0 + df = minmax_normalization(df) + df = self.writer.assign_coord_ids(df) + return self.writer.insert_ec_data(df) + + def add_predictors(self, df: "pd.DataFrame") -> "pd.DataFrame": + for predictor in [*EC_PREDICTORS, "ELEVATION"]: + if predictor in {"DOY", "TOD"}: + continue + required = VARIABLES_FOR_PREDICTOR.get(predictor) + if not required or not all(column in df.columns for column in required): + continue + values = df[required].to_numpy(dtype=float) + processor = PROCESSORS.get(predictor) + if processor is None: + df[predictor] = values[:, 0] + else: + df[predictor] = processor(*[values[:, index] for index in range(values.shape[1])]) + if "ELEVATION" in df.columns: + df["elev"] = df["ELEVATION"] + elif "geopotential" in df.columns: + df["elev"] = df["geopotential"] / GRAVITATIONAL_ACC + else: + df["elev"] = np.nan + return df + + def apply_local_window(self, df: "pd.DataFrame") -> "pd.DataFrame": + start, end = self.local_window + if start is None and end is None: + return df + timestamp_source = "_local_timestamp" if "_local_timestamp" in df.columns else "timestamp" + timestamp_text = df[timestamp_source].astype("int64").astype(str) + local_dt = pd.to_datetime(timestamp_text, format="%Y%m%d%H%M%S") + keep = pd.Series(True, index=df.index) + if start is not None: + keep &= local_dt >= start + if end is not None: + keep &= local_dt <= end + return df.loc[keep].copy() + + +def iter_netcdf_group_dirs(netcdf_dir: Path) -> Iterable[Path]: + paths = sorted(netcdf_dir.rglob("*.nc")) + seen: set[Path] = set() + for path in paths: + parent = path.parent + if parent in seen: + continue + seen.add(parent) + yield parent + + +def open_dataset(path: Path, engine: str | None): + kwargs = {"drop_variables": [name for name in ("number", "expver")]} + if engine: + kwargs["engine"] = engine + try: + return xr.open_dataset(path, **kwargs) + except ValueError: + kwargs.pop("drop_variables", None) + return xr.open_dataset(path, **kwargs) + + +def standardize_dataset(ds): + rename_map = { + old: new + for old, new in SHORTNAME_TO_FULLNAME.items() + if old in ds.data_vars and new not in ds.data_vars + } + coord_renames = {} + if "time" in ds.coords and "valid_time" not in ds.coords: + coord_renames["time"] = "valid_time" + if "lat" in ds.coords and "latitude" not in ds.coords: + coord_renames["lat"] = "latitude" + if "lon" in ds.coords and "longitude" not in ds.coords: + coord_renames["lon"] = "longitude" + return ds.rename({**rename_map, **coord_renames}) + + +def standardize_dataframe(df: "pd.DataFrame") -> "pd.DataFrame": + rename_map = { + "latitude": "lat", + "longitude": "lon", + "valid_time": "timestamp_utc", + "time": "timestamp_utc", + } + df = df.rename(columns={old: new for old, new in rename_map.items() if old in df.columns}) + for column in ["number", "expver", "region_id", "spatial_ref"]: + if column in df.columns: + df = df.drop(columns=column) + if "lat" not in df.columns or "lon" not in df.columns: + raise RuntimeError("ERA5 frame is missing latitude/longitude columns.") + df = df.dropna(subset=["lat", "lon"]).copy() + df["lat"] = df["lat"].astype(float) + df["lon"] = normalize_longitudes(df["lon"]) + return df + + +def minmax_normalization(df: "pd.DataFrame") -> "pd.DataFrame": + for predictor in EC_PREDICTORS: + if predictor not in df.columns: + continue + vmax = float(DEFAULT_NORM[predictor]["norm_max"]) + vmin = float(DEFAULT_NORM[predictor]["norm_min"]) + vmid = (vmax + vmin) / 2.0 + vrange = vmax - vmin + cyclic = bool(DEFAULT_NORM[predictor]["cyclic"]) + values = pd.to_numeric(df[predictor], errors="coerce") + + if cyclic: + period = abs(vrange) + high = max(vmax, vmin) + low = min(vmax, vmin) + values = values.where(values <= high, values - period) + values = values.where(values >= low, values + period) + vrange /= 2.0 + + low = min(vmin, vmax) + high = max(vmin, vmax) + values = values.where(values.between(low, high), np.nan) + df[predictor] = (values - vmid) / vrange + return df + + +def configure_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: + processing_config = section(config, "processing") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + conn.execute("PRAGMA temp_store = FILE") + conn.execute(f"PRAGMA threads = {int(processing_config.get('sqlite_threads', 8))}") + temp_dir = resolve_path(processing_config.get("sqlite_temp_dir")) + if temp_dir is not None: + temp_dir.mkdir(parents=True, exist_ok=True) + try: + conn.execute(f"PRAGMA temp_store_directory = {str(temp_dir)!r}") + except sqlite3.OperationalError as exc: + print(f"Could not set sqlite temp_store_directory: {exc}", file=sys.stderr) + + +def create_indexes(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_ec_data_coord_id_timestamp_id + ON ec_data(coord_id, timestamp, id) + """ + ) + conn.execute("ANALYZE") + + +def process_to_database(config: dict[str, Any], args: argparse.Namespace) -> None: + paths = section(config, "paths") + output_dir = resolve_path( + paths.get("output_dir"), + default=REPO_ROOT / "experiments/data/raw_era5", + ) + db_path = resolve_path(paths.get("db_path"), default=output_dir / DEFAULT_DB_FILENAME) + netcdf_dir = resolve_path(paths.get("netcdf_dir"), default=output_dir / "era5_data") + assert output_dir is not None and db_path is not None and netcdf_dir is not None + + processing_config = section(config, "processing") + recreate_db = bool(processing_config.get("recreate_db", True) or args.overwrite_db) + if args.dry_run: + groups = list(iter_netcdf_group_dirs(netcdf_dir)) if netcdf_dir.exists() else [] + print(f"Would process {len(groups)} NetCDF group(s) into {db_path}") + return + ensure_dependencies("pandas", "xarray") + output_dir.mkdir(parents=True, exist_ok=True) + db_path.parent.mkdir(parents=True, exist_ok=True) + if db_path.exists() and recreate_db: + db_path.unlink() + + with sqlite3.connect(db_path, timeout=60) as conn: + configure_sqlite(conn, config) + init_sqlite(conn, config) + processor = Era5PostProcessor(config, conn) + total_rows = processor.process_groups(netcdf_dir, args.limit_groups) + if processing_config.get("create_index", True): + print("Creating SQLite indexes...") + create_indexes(conn) + conn.commit() + print(f"SQLite export complete: {db_path} ({total_rows:,} ec_data rows inserted)") + + +def main() -> int: + args = parse_args() + config = load_config(args.config) + + download_enabled = bool(section(config, "download").get("enabled", True)) + if not args.process_only and download_enabled: + download_groups(config, args) + elif args.download_only: + print("Download disabled by config; nothing to do.") + + if not args.download_only: + process_to_database(config, args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/modis/download_modis.py b/era5_download/modis/download_modis.py similarity index 100% rename from scripts/modis/download_modis.py rename to era5_download/modis/download_modis.py diff --git a/scripts/modis/index_era5.py b/era5_download/modis/index_era5.py similarity index 100% rename from scripts/modis/index_era5.py rename to era5_download/modis/index_era5.py diff --git a/scripts/modis/pipeline.py b/era5_download/modis/pipeline.py similarity index 98% rename from scripts/modis/pipeline.py rename to era5_download/modis/pipeline.py index 878355a..eabdb35 100644 --- a/scripts/modis/pipeline.py +++ b/era5_download/modis/pipeline.py @@ -487,7 +487,7 @@ def command_for_step(step: str, args: argparse.Namespace) -> list[str]: def download_modis_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(REPO_ROOT / "scripts" / "modis" / "download_modis.py"), + str(SCRIPT_DIR / "download_modis.py"), ] if args.download_start_date is not None: command.append(str(args.download_start_date)) @@ -511,7 +511,7 @@ def download_modis_command(args: argparse.Namespace) -> list[str]: def update_igbp_from_c1_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(REPO_ROOT / "scripts" / "modis" / "update_igbp_from_c1.py"), + str(SCRIPT_DIR / "update_igbp_from_c1.py"), "--db-path", str(args.db_path), "--c1-path", @@ -531,7 +531,7 @@ def update_igbp_from_c1_command(args: argparse.Namespace) -> list[str]: def transform_modis_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(REPO_ROOT / "scripts" / "modis" / "transform_modis.py"), + str(SCRIPT_DIR / "transform_modis.py"), "--input-dir", str(args.modis_input_dir), "--db-path", @@ -553,7 +553,7 @@ def transform_modis_command(args: argparse.Namespace) -> list[str]: def index_era5_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(REPO_ROOT / "scripts" / "modis" / "index_era5.py"), + str(SCRIPT_DIR / "index_era5.py"), "--db-path", str(args.db_path), "--table", diff --git a/scripts/modis/pipeline.sh b/era5_download/modis/pipeline.sh similarity index 53% rename from scripts/modis/pipeline.sh rename to era5_download/modis/pipeline.sh index 09e7c51..3ce4d19 100755 --- a/scripts/modis/pipeline.sh +++ b/era5_download/modis/pipeline.sh @@ -4,8 +4,8 @@ #SBATCH --mem=128G #SBATCH --cpus-per-task=8 #SBATCH --time=24:00:00 -#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/scripts/modis/logs/pipeline_%j.out -#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/scripts/modis/logs/pipeline_%j.error +#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/modis/logs/pipeline_%j.out +#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/modis/logs/pipeline_%j.error #SBATCH --job-name=modis-pipeline #SBATCH --account=aip-pal @@ -13,12 +13,12 @@ set -euo pipefail source "$SCRATCH/env/ecoperceiver/bin/activate" cd "$HOME/links/scratch/EcoPerceiver" -mkdir -p scripts/modis/logs +mkdir -p era5_download/modis/logs export PYTHONPATH=. export PYTHONUNBUFFERED=1 export OMP_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" -python -u scripts/modis/pipeline.py \ - --config-path scripts/modis/pipeline_config.yml \ +python -u era5_download/modis/pipeline.py \ + --config-path era5_download/modis/pipeline_config.yml \ "$@" diff --git a/scripts/modis/pipeline_config.yml b/era5_download/modis/pipeline_config.yml similarity index 100% rename from scripts/modis/pipeline_config.yml rename to era5_download/modis/pipeline_config.yml diff --git a/scripts/modis/plot_igbp_map.py b/era5_download/modis/plot_igbp_map.py similarity index 100% rename from scripts/modis/plot_igbp_map.py rename to era5_download/modis/plot_igbp_map.py diff --git a/scripts/modis/rebuild_ids_era5.py b/era5_download/modis/rebuild_ids_era5.py similarity index 100% rename from scripts/modis/rebuild_ids_era5.py rename to era5_download/modis/rebuild_ids_era5.py diff --git a/scripts/modis/transform_modis.py b/era5_download/modis/transform_modis.py similarity index 99% rename from scripts/modis/transform_modis.py rename to era5_download/modis/transform_modis.py index 47fc246..dbb882c 100644 --- a/scripts/modis/transform_modis.py +++ b/era5_download/modis/transform_modis.py @@ -351,7 +351,7 @@ def load_coord_lookup( if not index_has_leading_column(conn, table="ec_data", column="coord_id"): raise RuntimeError( "`--active-ec-coords-only` requires an ec_data index whose first " - "column is coord_id. Run `scripts/modis/index_era5.py` first to " + "column is coord_id. Run `era5_download/modis/index_era5.py` first to " "create idx_ec_data_coord_id_timestamp_id, or pass " "`--no-active-ec-coords-only`." ) diff --git a/scripts/modis/update_igbp_from_c1.py b/era5_download/modis/update_igbp_from_c1.py similarity index 100% rename from scripts/modis/update_igbp_from_c1.py rename to era5_download/modis/update_igbp_from_c1.py From 390117ecb48d96cfeb4eec6ff1aaae347bd14ff0 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Thu, 18 Jun 2026 12:32:02 -0400 Subject: [PATCH 02/14] pipeline unified and download_era5.py based on CarbonCast by @mrquaternion --- ...igbp_from_c1.py => assign_igbp_from_c1.py} | 68 +- era5_download/{era5 => }/download_era5.py | 98 +-- era5_download/{modis => }/download_modis.py | 15 +- era5_download/modis/index_era5.py | 706 ------------------ era5_download/modis/pipeline_config.yml | 47 -- era5_download/modis/rebuild_ids_era5.py | 294 -------- era5_download/{modis => }/pipeline.py | 260 +++---- era5_download/{modis => }/pipeline.sh | 15 +- .../{era5/config.yml => pipeline_config.yml} | 41 +- era5_download/{modis => }/transform_modis.py | 6 +- .../modis => scripts}/plot_igbp_map.py | 2 +- 11 files changed, 200 insertions(+), 1352 deletions(-) rename era5_download/{modis/update_igbp_from_c1.py => assign_igbp_from_c1.py} (84%) rename era5_download/{era5 => }/download_era5.py (93%) rename era5_download/{modis => }/download_modis.py (97%) delete mode 100644 era5_download/modis/index_era5.py delete mode 100644 era5_download/modis/pipeline_config.yml delete mode 100644 era5_download/modis/rebuild_ids_era5.py rename era5_download/{modis => }/pipeline.py (70%) rename era5_download/{modis => }/pipeline.sh (56%) rename era5_download/{era5/config.yml => pipeline_config.yml} (71%) rename era5_download/{modis => }/transform_modis.py (99%) rename {era5_download/modis => scripts}/plot_igbp_map.py (99%) mode change 100755 => 100644 diff --git a/era5_download/modis/update_igbp_from_c1.py b/era5_download/assign_igbp_from_c1.py similarity index 84% rename from era5_download/modis/update_igbp_from_c1.py rename to era5_download/assign_igbp_from_c1.py index 957ba3e..c215848 100755 --- a/era5_download/modis/update_igbp_from_c1.py +++ b/era5_download/assign_igbp_from_c1.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Update coord_data.igbp from a MODIS MCD12C1 GeoTIFF.""" +"""Assign coord_data.igbp from a MODIS MCD12C1 GeoTIFF.""" from __future__ import annotations @@ -18,7 +18,7 @@ from tqdm.auto import tqdm -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT)) from ecoperceiver.constants import IGBP_ACRONYMS_MODIS @@ -45,7 +45,7 @@ def width(self) -> int: @dataclass(frozen=True) -class PlannedUpdate: +class PlannedAssignment: rowid: int new_igbp: str @@ -53,7 +53,7 @@ class PlannedUpdate: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( - "Sample MODIS MCD12C1 Majority_Land_Cover_Type_1 values and update " + "Sample MODIS MCD12C1 Majority_Land_Cover_Type_1 values and assign " "coord_data.igbp labels in an ERA5 SQLite database. The default is " "a dry run; pass --write to modify the database." ) @@ -62,7 +62,7 @@ def parse_args() -> argparse.Namespace: "--db-path", type=Path, default=DEFAULT_DB_PATH, - help=f"SQLite database to update. Default: {DEFAULT_DB_PATH}", + help=f"SQLite database to modify. Default: {DEFAULT_DB_PATH}", ) parser.add_argument( "--c1-path", @@ -73,23 +73,23 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--table", default=DEFAULT_TABLE, - help=f"Coordinate table to update. Default: {DEFAULT_TABLE}", + help=f"Coordinate table to assign. Default: {DEFAULT_TABLE}", ) parser.add_argument( "--only-null", action="store_true", - help="Only update rows where coord_data.igbp is NULL.", + help="Only assign rows where coord_data.igbp is NULL.", ) parser.add_argument( "--write", action="store_true", - help="Apply the planned updates. Without this flag, only a dry run is printed.", + help="Apply the planned assignments. Without this flag, only a dry run is printed.", ) parser.add_argument( "--batch-size", type=int, default=DEFAULT_BATCH_SIZE, - help=f"SQLite update batch size. Default: {DEFAULT_BATCH_SIZE}", + help=f"SQLite assignment batch size. Default: {DEFAULT_BATCH_SIZE}", ) return parser.parse_args() @@ -229,15 +229,15 @@ def iter_coord_row_batches( yield rows -def plan_updates( +def plan_assignments( conn: sqlite3.Connection, table: str, grid: RasterGrid, *, only_null: bool, batch_size: int, -) -> tuple[list[PlannedUpdate], Counter[str], Counter[str], Counter[tuple[str, str]], int, int]: - updates: list[PlannedUpdate] = [] +) -> tuple[list[PlannedAssignment], Counter[str], Counter[str], Counter[tuple[str, str]], int, int]: + assignments: list[PlannedAssignment] = [] old_counts: Counter[str] = Counter() new_counts: Counter[str] = Counter() transitions: Counter[tuple[str, str]] = Counter() @@ -246,7 +246,7 @@ def plan_updates( with tqdm( total=row_count, - desc="update_igbp plan", + desc="assign_igbp plan", unit="coord", dynamic_ncols=True, ) as progress: @@ -287,8 +287,8 @@ def plan_updates( transitions[(old_label, new_igbp)] += 1 if old_igbp != new_igbp: - updates.append( - PlannedUpdate( + assignments.append( + PlannedAssignment( rowid=int(rowid), new_igbp=new_igbp, ) @@ -296,7 +296,7 @@ def plan_updates( progress.update(len(batch)) - return updates, old_counts, new_counts, transitions, skipped_missing_coords, row_count + return assignments, old_counts, new_counts, transitions, skipped_missing_coords, row_count def print_counts(title: str, counts: Counter[str]) -> None: @@ -312,7 +312,7 @@ def print_summary( table: str, only_null: bool, row_count: int, - updates: list[PlannedUpdate], + assignments: list[PlannedAssignment], old_counts: Counter[str], new_counts: Counter[str], transitions: Counter[tuple[str, str]], @@ -328,7 +328,7 @@ def print_summary( else: print("Scope: all coord_data rows") print(f"Rows with missing lat/lon skipped: {skipped_missing_coords:,}") - print(f"Rows that would change: {len(updates):,}") + print(f"Rows that would be assigned/changed: {len(assignments):,}") print() print_counts("Current labels in scan scope:", old_counts) print() @@ -340,10 +340,10 @@ def print_summary( print(f" {old_label} -> {new_label}: {count:,}") -def apply_updates( +def apply_assignments( conn: sqlite3.Connection, table: str, - updates: list[PlannedUpdate], + assignments: list[PlannedAssignment], *, batch_size: int, ) -> None: @@ -351,25 +351,25 @@ def apply_updates( raise SystemExit("--batch-size must be positive.") table_sql = quote_identifier(table) - update_sql = f"UPDATE {table_sql} SET igbp = ? WHERE rowid = ?" + assignment_sql = f"UPDATE {table_sql} SET igbp = ? WHERE rowid = ?" started_at = time.monotonic() with conn: with tqdm( - total=len(updates), - desc="update_igbp write", + total=len(assignments), + desc="assign_igbp write", unit="row", dynamic_ncols=True, ) as progress: - for offset in range(0, len(updates), batch_size): - batch = updates[offset : offset + batch_size] + for offset in range(0, len(assignments), batch_size): + batch = assignments[offset : offset + batch_size] conn.executemany( - update_sql, - [(update.new_igbp, update.rowid) for update in batch], + assignment_sql, + [(assignment.new_igbp, assignment.rowid) for assignment in batch], ) progress.update(len(batch)) - print(f"Applied updates in {format_duration(time.monotonic() - started_at)}") + print(f"Applied assignments in {format_duration(time.monotonic() - started_at)}") def main() -> int: @@ -383,7 +383,7 @@ def main() -> int: grid = load_c1_raster(c1_path) with connect_database(db_path, readonly=not args.write) as conn: ensure_coord_table(conn, args.table) - updates, old_counts, new_counts, transitions, skipped_missing_coords, row_count = plan_updates( + assignments, old_counts, new_counts, transitions, skipped_missing_coords, row_count = plan_assignments( conn, args.table, grid, @@ -396,7 +396,7 @@ def main() -> int: table=args.table, only_null=args.only_null, row_count=row_count, - updates=updates, + assignments=assignments, old_counts=old_counts, new_counts=new_counts, transitions=transitions, @@ -405,14 +405,14 @@ def main() -> int: if not args.write: print() - print("Dry run only; rerun with --write to update the database.") + print("Dry run only; rerun with --write to assign IGBP in the database.") return 0 - if not updates: - print("No updates needed.") + if not assignments: + print("No assignments needed.") return 0 - apply_updates(conn, args.table, updates, batch_size=args.batch_size) + apply_assignments(conn, args.table, assignments, batch_size=args.batch_size) return 0 diff --git a/era5_download/era5/download_era5.py b/era5_download/download_era5.py similarity index 93% rename from era5_download/era5/download_era5.py rename to era5_download/download_era5.py index f167384..77418fc 100644 --- a/era5_download/era5/download_era5.py +++ b/era5_download/download_era5.py @@ -30,9 +30,9 @@ import numpy as np -REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT_DIR = Path(__file__).resolve().parent -DEFAULT_CONFIG_PATH = SCRIPT_DIR / "config.yml" +REPO_ROOT = SCRIPT_DIR.parent +DEFAULT_CONFIG_PATH = SCRIPT_DIR / "pipeline_config.yml" DEFAULT_DB_FILENAME = "era5_2016_2017.db" if str(REPO_ROOT) not in sys.path: @@ -64,12 +64,6 @@ MISSING_DEPENDENCIES["cdsapi"] = "cdsapi" cdsapi = None -try: - import rasterio -except ModuleNotFoundError: - MISSING_DEPENDENCIES["rasterio"] = "rasterio" - rasterio = None - try: from timezonefinder import TimezoneFinder except ModuleNotFoundError: @@ -83,29 +77,6 @@ from ecoperceiver.constants import DEFAULT_NORM, EC_PREDICTORS -try: - from ecoperceiver.constants import IGBP_ACRONYMS_MODIS -except ImportError: - IGBP_ACRONYMS_MODIS = { - 0: "WAT", - 1: "ENF", - 2: "EBF", - 3: "DNF", - 4: "DBF", - 5: "MF", - 6: "CSH", - 7: "OSH", - 8: "WSA", - 9: "SAV", - 10: "GRA", - 11: "WET", - 12: "CRO", - 13: "URB", - 14: "CVM", - 15: "SNO", - 16: "BSV", - } - ERA5_VARIABLES = [ "10m_u_component_of_wind", @@ -732,7 +703,7 @@ def init_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: """ ) metadata = { - "generator": "era5_download/era5/download_era5.py", + "generator": "era5_download/download_era5.py", "config_metadata": section(config, "metadata"), "years": config.get("years"), "created_utc": datetime.now(timezone.utc).isoformat(), @@ -884,13 +855,15 @@ def datetimes_to_int(values: "pd.Series") -> "pd.Series": class LandSeaMask: def __init__(self, config: dict[str, Any]): - mask_config = section(section(config, "processing"), "land_sea_mask") + processing_config = section(config, "processing") + mask_config = section(processing_config, "land_sea_mask") + path_config = section(config, "paths") self.enabled = bool(mask_config.get("enabled", False)) self.threshold = float(mask_config.get("threshold", 0.5)) self.include_land_neighbors = bool(mask_config.get("include_land_neighbors", True)) self.cache: dict[tuple[float, float], bool] = {} self.da = None - path = resolve_path(mask_config.get("path")) + path = resolve_path(mask_config.get("path") or path_config.get("lsm_path")) if not self.enabled: return if path is None or not path.exists(): @@ -899,7 +872,10 @@ def __init__(self, config: dict[str, Any]): self.enabled = False return raise SystemExit(f"Land-sea mask file does not exist: {path}") - ds = xr.open_dataset(path, engine=mask_config.get("xarray_engine")) + ds = xr.open_dataset( + path, + engine=mask_config.get("xarray_engine") or processing_config.get("xarray_engine"), + ) variable = mask_config.get("variable") if variable is None: variable = "lsm" if "lsm" in ds.data_vars else next(iter(ds.data_vars)) @@ -957,55 +933,6 @@ def neighbor_any(land: np.ndarray) -> np.ndarray: return neighbors -class IGBPSampler: - def __init__(self, config: dict[str, Any]): - igbp_config = section(section(config, "processing"), "igbp") - self.enabled = bool(igbp_config.get("enabled", False)) - self.cache: dict[tuple[float, float], str | None] = {} - self.src = None - path = resolve_path(igbp_config.get("raster_path")) - if not self.enabled: - return - if path is None or not path.exists(): - if igbp_config.get("allow_missing", False): - print(f"IGBP raster not found; writing NULL igbp values: {path}", file=sys.stderr) - self.enabled = False - return - raise SystemExit(f"IGBP raster does not exist: {path}") - if rasterio is None: - if igbp_config.get("allow_missing", False): - print("rasterio is not installed; writing NULL igbp values.", file=sys.stderr) - self.enabled = False - return - ensure_dependencies("rasterio") - self.src = rasterio.open(path) - - def assign(self, df: "pd.DataFrame") -> "pd.DataFrame": - if not self.enabled or self.src is None or df.empty: - df["igbp"] = None - return df - coords = df[["lat", "lon"]].drop_duplicates() - missing_rows = [ - row - for row in coords.itertuples(index=False) - if rounded_coord_key(row.lat, row.lon) not in self.cache - ] - if missing_rows: - points = [(float(row.lon), float(row.lat)) for row in missing_rows] - for row, sample in zip(missing_rows, self.src.sample(points)): - raw_value = sample[0] - key = rounded_coord_key(row.lat, row.lon) - try: - code = int(raw_value) - except (TypeError, ValueError): - self.cache[key] = None - else: - self.cache[key] = IGBP_ACRONYMS_MODIS.get(code) - keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) - df["igbp"] = keys.map(self.cache) - return df - - class Era5DatabaseWriter: def __init__(self, conn: sqlite3.Connection, batch_size: int): self.conn = conn @@ -1082,7 +1009,6 @@ def __init__(self, config: dict[str, Any], conn: sqlite3.Connection): self.xarray_engine = processing_config.get("xarray_engine") self.timezone_resolver = TimezoneResolver(config) self.land_mask = LandSeaMask(config) - self.igbp_sampler = IGBPSampler(config) self.writer = Era5DatabaseWriter(conn, self.batch_size) self.local_window = self.parse_local_window() @@ -1139,7 +1065,7 @@ def process_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> in if df.empty: return 0 df = self.add_predictors(df) - df = self.igbp_sampler.assign(df) + df["igbp"] = None df = self.timezone_resolver.localize_frame(df, utc_timestamp) df = self.apply_local_window(df) if df.empty: diff --git a/era5_download/modis/download_modis.py b/era5_download/download_modis.py similarity index 97% rename from era5_download/modis/download_modis.py rename to era5_download/download_modis.py index ef3ce43..c80081f 100755 --- a/era5_download/modis/download_modis.py +++ b/era5_download/download_modis.py @@ -48,17 +48,14 @@ tqdm = None HIGH_VOLUME_URL = "https://earthengine-highvolume.googleapis.com" -REPO_ROOT = Path(__file__).resolve().parents[2] -# DEFAULT_OUTPUT_DIR = REPO_ROOT / "experiments" / "data" / "raw_modis" -DEFAULT_OUTPUT_DIR = REPO_ROOT / "experiments" / "data" / "test_raw_modis" +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_OUTPUT_DIR = REPO_ROOT / "experiments" / "data" / "raw_modis" DEFAULT_PROJECT = "modis-488716" -# DEFAULT_START_DATE = date(2016, 12, 31) -# DEFAULT_END_DATE = date(2018, 1, 1) -DEFAULT_START_DATE = date(2010, 6, 1) -DEFAULT_END_DATE = date(2010, 6, 2) +DEFAULT_START_DATE = date(2016, 1, 1) +DEFAULT_END_DATE = date(2017, 12, 31) DEFAULT_TARGET_SCALE = 1 / 32 DEFAULT_TILE_SIZE_DEG = 30 -DEFAULT_MAX_WORKERS = 64 +DEFAULT_MAX_WORKERS = 8 @dataclass(frozen=True) @@ -135,7 +132,7 @@ def parse_args() -> argparse.Namespace: description=( "Download global MODIS rasters for an inclusive date range. " "Annual C1 land-cover files are downloaded once per year touched " - "by the requested range. Defaults to 2016-12-31 through 2018-01-01." + "by the requested range. Defaults to 2016-01-01 through 2017-12-31." ) ) parser.add_argument( diff --git a/era5_download/modis/index_era5.py b/era5_download/modis/index_era5.py deleted file mode 100644 index 7e48965..0000000 --- a/era5_download/modis/index_era5.py +++ /dev/null @@ -1,706 +0,0 @@ -#!/usr/bin/env python3 -"""Create persistent SQLite indexes for ERA5 evaluation.""" - -from __future__ import annotations - -import argparse -from contextlib import closing, contextmanager -import os -import shutil -import sqlite3 -import sys -import time -from pathlib import Path -from urllib.parse import quote - -try: - from tqdm.auto import tqdm -except ModuleNotFoundError: - tqdm = None - - -DEFAULT_DB_PATH = Path("/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db") -DEFAULT_TABLE = "ec_data" -DEFAULT_INDEX_NAME = "idx_ec_data_coord_id_timestamp_id" -DEFAULT_INDEX_COLUMNS = ("coord_id", "timestamp", "id") -SQLITE_PROGRESS_OPCODES = 100_000 -HEARTBEAT_SECONDS = 15.0 -PROGRESS_STATUS_SECONDS = 2.0 -SQLITE_TIMEOUT_SECONDS = 60.0 -ESTIMATED_INDEX_BYTES_PER_ROW = 32.0 -ESTIMATED_TEMP_SORT_MULTIPLIER = 1.25 -MIN_FREE_SPACE_MARGIN_BYTES = 10 * 1024**3 -DEFAULT_BUILD_JOURNAL_MODE = "DELETE" -DEFAULT_PROGRESS_MODE = "auto" -DEFAULT_SQLITE_THREADS = 8 - - -def quote_identifier(identifier: str) -> str: - return '"' + identifier.replace('"', '""') + '"' - - -def format_bytes(num_bytes: float) -> str: - units = ("B", "KiB", "MiB", "GiB", "TiB") - value = float(num_bytes) - for unit in units: - if abs(value) < 1024.0 or unit == units[-1]: - return f"{value:.1f} {unit}" if unit != "B" else f"{value:.0f} {unit}" - value /= 1024.0 - return f"{value:.1f} TiB" - - -def safe_file_size(path: Path) -> int: - try: - return path.stat().st_size - except OSError: - return 0 - - -def process_temp_file_bytes(temp_dir: Path) -> int: - """Return bytes held by SQLite temp files, including unlinked Linux files.""" - fd_dir = Path("/proc") / str(os.getpid()) / "fd" - temp_prefix = f"{temp_dir}{os.sep}" - total = 0 - seen: set[tuple[int, int]] = set() - - if fd_dir.exists(): - try: - fds = list(fd_dir.iterdir()) - except OSError: - fds = [] - for fd in fds: - try: - target = os.readlink(fd) - except OSError: - continue - if not target.startswith(temp_prefix): - continue - try: - stat_result = fd.stat() - except OSError: - continue - key = (stat_result.st_dev, stat_result.st_ino) - if key in seen: - continue - seen.add(key) - total += stat_result.st_size - - try: - paths = list(temp_dir.iterdir()) - except OSError: - paths = [] - for path in paths: - try: - stat_result = path.stat() - except OSError: - continue - key = (stat_result.st_dev, stat_result.st_ino) - if key in seen: - continue - seen.add(key) - if path.is_file(): - total += stat_result.st_size - - return total - - -def progress_status_text( - *, - opcodes: int, - started_at: float, - db_path: Path | None, - temp_dir: Path | None, -) -> str: - elapsed = max(time.monotonic() - started_at, 1e-9) - parts = [ - f"opcodes={opcodes:,}", - f"elapsed={elapsed / 60.0:.1f} min", - f"rate={opcodes / elapsed:,.0f} opcode/s", - ] - - if db_path is not None: - db_bytes = safe_file_size(db_path) - wal_bytes = safe_file_size(db_path.with_name(f"{db_path.name}-wal")) - journal_bytes = safe_file_size(db_path.with_name(f"{db_path.name}-journal")) - parts.append(f"db={format_bytes(db_bytes)}") - if wal_bytes: - parts.append(f"wal={format_bytes(wal_bytes)}") - if journal_bytes: - parts.append(f"journal={format_bytes(journal_bytes)}") - - if temp_dir is not None: - temp_bytes = process_temp_file_bytes(temp_dir) - parts.append(f"temp={format_bytes(temp_bytes)}") - try: - free_bytes = shutil.disk_usage(existing_path_for_disk_check(temp_dir)).free - parts.append(f"free={format_bytes(free_bytes)}") - except RuntimeError: - pass - - return ", ".join(parts) - - -class SqliteProgress: - def __init__( - self, - desc: str, - *, - db_path: Path | None, - temp_dir: Path | None, - progress_mode: str, - ): - self.desc = desc - self.db_path = db_path - self.temp_dir = temp_dir - self.progress_mode = self.resolve_progress_mode(progress_mode) - self.opcodes = 0 - self.started_at = time.monotonic() - self.last_heartbeat = self.started_at - self.last_status = self.started_at - self.progress = ( - tqdm( - total=None, - desc=desc, - unit="opcode", - unit_scale=True, - dynamic_ncols=True, - mininterval=1.0, - ) - if self.progress_mode == "tqdm" - else None - ) - - @staticmethod - def resolve_progress_mode(progress_mode: str) -> str: - if progress_mode == "none": - return "none" - if progress_mode == "heartbeat": - return "heartbeat" - if progress_mode == "tqdm": - if tqdm is None: - print("tqdm is not installed; falling back to heartbeat progress.", flush=True) - return "heartbeat" - return "tqdm" - if tqdm is not None and sys.stderr.isatty(): - return "tqdm" - return "heartbeat" - - def status_text(self) -> str: - try: - return progress_status_text( - opcodes=self.opcodes, - started_at=self.started_at, - db_path=self.db_path, - temp_dir=self.temp_dir, - ) - except OSError as exc: - return f"opcodes={self.opcodes:,}, status unavailable: {exc}" - - def update(self) -> int: - self.opcodes += SQLITE_PROGRESS_OPCODES - if self.progress_mode == "none": - return 0 - - now = time.monotonic() - if self.progress is not None: - self.progress.update(SQLITE_PROGRESS_OPCODES) - if now - self.last_status >= PROGRESS_STATUS_SECONDS: - self.progress.set_postfix_str(self.status_text(), refresh=True) - self.last_status = now - return 0 - - if now - self.last_heartbeat >= HEARTBEAT_SECONDS: - print(f"{self.desc}: {self.status_text()}", flush=True) - self.last_heartbeat = now - return 0 - - def close(self) -> None: - if self.progress is not None: - self.progress.set_postfix_str(self.status_text(), refresh=True) - self.progress.close() - - -@contextmanager -def sqlite_progress( - conn: sqlite3.Connection, - desc: str, - *, - db_path: Path | None, - temp_dir: Path | None, - progress_mode: str, -): - progress = SqliteProgress( - desc, - db_path=db_path, - temp_dir=temp_dir, - progress_mode=progress_mode, - ) - conn.set_progress_handler(progress.update, SQLITE_PROGRESS_OPCODES) - try: - yield - finally: - conn.set_progress_handler(None, 0) - progress.close() - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Create the composite ec_data index used by ERA5 eval startup and " - "active MODIS coordinate lookup." - ) - ) - parser.add_argument( - "--db-path", - type=Path, - default=DEFAULT_DB_PATH, - help=f"SQLite database to index. Default: {DEFAULT_DB_PATH}", - ) - parser.add_argument( - "--table", - default=DEFAULT_TABLE, - help=f"Table to index. Default: {DEFAULT_TABLE}", - ) - parser.add_argument( - "--index-name", - default=DEFAULT_INDEX_NAME, - help=f"Composite index name. Default: {DEFAULT_INDEX_NAME}", - ) - parser.add_argument( - "--columns", - nargs="+", - default=list(DEFAULT_INDEX_COLUMNS), - help=( - "Columns for the composite index. " - f"Default: {' '.join(DEFAULT_INDEX_COLUMNS)}" - ), - ) - parser.add_argument( - "--analyze", - action=argparse.BooleanOptionalAction, - default=True, - help="Run ANALYZE after index changes so SQLite picks the new index. Default: true.", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print planned index changes without modifying the database.", - ) - parser.add_argument( - "--temp-dir", - type=Path, - default=None, - help=( - "Directory for SQLite temporary sorter files. Default: a sqlite-tmp " - "directory next to the database." - ), - ) - parser.add_argument( - "--journal-mode", - choices=("DELETE", "TRUNCATE", "PERSIST", "WAL"), - default=DEFAULT_BUILD_JOURNAL_MODE, - help=( - "Journal mode to use while building the index. DELETE avoids writing " - "the full index through a large WAL file. Default: DELETE." - ), - ) - parser.add_argument( - "--skip-preflight", - action="store_true", - help="Skip disk/page-count estimates before creating a missing index.", - ) - parser.add_argument( - "--estimate-index-bytes-per-row", - type=float, - default=ESTIMATED_INDEX_BYTES_PER_ROW, - help=( - "Preflight estimate for final index bytes per table row. " - f"Default: {ESTIMATED_INDEX_BYTES_PER_ROW:g}." - ), - ) - parser.add_argument( - "--progress", - choices=("auto", "tqdm", "heartbeat", "none"), - default=DEFAULT_PROGRESS_MODE, - help=( - "Progress display. auto uses tqdm on an interactive terminal and " - "heartbeat log lines otherwise. tqdm is indeterminate because " - "SQLite does not expose total CREATE INDEX work. Default: auto." - ), - ) - parser.add_argument( - "--threads", - type=int, - default=DEFAULT_SQLITE_THREADS, - help=( - "SQLite worker threads for sort operations such as CREATE INDEX. " - f"Default: {DEFAULT_SQLITE_THREADS}." - ), - ) - return parser.parse_args() - - -def ensure_valid_args(args: argparse.Namespace) -> None: - args.db_path = args.db_path.expanduser().resolve() - if not args.db_path.exists(): - raise SystemExit(f"Database does not exist: {args.db_path}") - if not args.columns: - raise SystemExit("--columns must contain at least one column.") - if args.temp_dir is None: - args.temp_dir = args.db_path.parent / "sqlite-tmp" - else: - args.temp_dir = args.temp_dir.expanduser().resolve() - if args.estimate_index_bytes_per_row <= 0: - raise SystemExit("--estimate-index-bytes-per-row must be positive.") - if args.threads < 0: - raise SystemExit("--threads must be zero or greater.") - - -def connect_database(db_path: Path, *, readonly: bool) -> sqlite3.Connection: - if readonly: - db_uri = f"file:{quote(str(db_path), safe='/')}?mode=ro" - return sqlite3.connect(db_uri, timeout=SQLITE_TIMEOUT_SECONDS, uri=True) - return sqlite3.connect(db_path, timeout=SQLITE_TIMEOUT_SECONDS) - - -def table_columns(conn: sqlite3.Connection, table: str) -> set[str]: - rows = conn.execute(f"PRAGMA table_info({quote_identifier(table)})").fetchall() - if not rows: - raise RuntimeError(f"Table does not exist or has no columns: {table}") - return {row[1] for row in rows} - - -def ensure_table_has_columns( - conn: sqlite3.Connection, - table: str, - columns: list[str], -) -> None: - available_columns = table_columns(conn, table) - missing_columns = [column for column in columns if column not in available_columns] - if missing_columns: - raise RuntimeError( - f"Cannot index {table}; missing column(s): {', '.join(missing_columns)}" - ) - - -def index_columns(conn: sqlite3.Connection, index_name: str) -> tuple[str, ...] | None: - rows = conn.execute( - "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", - (index_name,), - ).fetchall() - if not rows: - return None - - return tuple( - column_row[2] - for column_row in conn.execute( - f"PRAGMA index_info({quote_identifier(index_name)})" - ) - ) - - -def table_row_estimate(conn: sqlite3.Connection, table: str) -> int: - sequence_exists = conn.execute( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'" - ).fetchone() - if sequence_exists is not None: - row = conn.execute( - "SELECT seq FROM sqlite_sequence WHERE name = ?", - (table,), - ).fetchone() - if row is not None and row[0] is not None: - return int(row[0]) - - try: - row = conn.execute( - f"SELECT MAX(rowid) FROM {quote_identifier(table)}" - ).fetchone() - except sqlite3.Error as exc: - raise RuntimeError( - f"Cannot cheaply estimate row count for {table!r}; " - "rerun with --skip-preflight if you want to proceed." - ) from exc - return int(row[0] or 0) - - -def sqlite_pragma_int(conn: sqlite3.Connection, pragma_name: str) -> int: - row = conn.execute(f"PRAGMA {pragma_name}").fetchone() - if row is None or row[0] is None: - raise RuntimeError(f"Could not read PRAGMA {pragma_name}.") - return int(row[0]) - - -def existing_path_for_disk_check(path: Path) -> Path: - current = path - while not current.exists(): - parent = current.parent - if parent == current: - raise RuntimeError(f"No existing parent directory found for {path}") - current = parent - return current - - -def same_filesystem(path_a: Path, path_b: Path) -> bool: - return os.stat(path_a).st_dev == os.stat(path_b).st_dev - - -def ensure_enough_space(path: Path, required_bytes: float, label: str) -> None: - free_bytes = shutil.disk_usage(path).free - print( - f"{label} free space: {format_bytes(free_bytes)} " - f"(estimated need: {format_bytes(required_bytes)})", - flush=True, - ) - if free_bytes < required_bytes: - raise RuntimeError( - f"Not enough free space on {path} for {label}: " - f"need about {format_bytes(required_bytes)}, " - f"available {format_bytes(free_bytes)}." - ) - - -def preflight_index_build( - conn: sqlite3.Connection, - *, - table: str, - db_path: Path, - temp_dir: Path, - estimate_index_bytes_per_row: float, -) -> None: - page_size = sqlite_pragma_int(conn, "page_size") - page_count = sqlite_pragma_int(conn, "page_count") - max_page_count = sqlite_pragma_int(conn, "max_page_count") - row_count = table_row_estimate(conn, table) - estimated_index_bytes = row_count * estimate_index_bytes_per_row - estimated_index_pages = int((estimated_index_bytes + page_size - 1) // page_size) - estimated_final_pages = page_count + estimated_index_pages - estimated_temp_bytes = estimated_index_bytes * ESTIMATED_TEMP_SORT_MULTIPLIER - - print("Preflight estimate:", flush=True) - print(f" table rows: {row_count:,}", flush=True) - print(f" current DB size: {format_bytes(page_count * page_size)}", flush=True) - print(f" estimated final index size: {format_bytes(estimated_index_bytes)}", flush=True) - print(f" estimated SQLite temp sort space: {format_bytes(estimated_temp_bytes)}", flush=True) - print( - f" estimated final page count: {estimated_final_pages:,} " - f"of {max_page_count:,}", - flush=True, - ) - - if estimated_final_pages >= max_page_count: - raise RuntimeError( - "The estimated final database page count exceeds SQLite's configured " - f"max_page_count ({max_page_count:,})." - ) - - db_parent = db_path.parent - temp_space_path = existing_path_for_disk_check(temp_dir) - if same_filesystem(db_parent, temp_space_path): - required_bytes = ( - estimated_index_bytes - + estimated_temp_bytes - + MIN_FREE_SPACE_MARGIN_BYTES - ) - ensure_enough_space(db_parent, required_bytes, "DB/temp filesystem") - else: - ensure_enough_space( - db_parent, - estimated_index_bytes + MIN_FREE_SPACE_MARGIN_BYTES, - "DB filesystem", - ) - ensure_enough_space( - temp_space_path, - estimated_temp_bytes + MIN_FREE_SPACE_MARGIN_BYTES, - "SQLite temp filesystem", - ) - - -def prepare_temp_dir(temp_dir: Path, *, dry_run: bool) -> None: - if dry_run: - print(f"SQLite temp dir: {temp_dir}") - return - - temp_dir.mkdir(parents=True, exist_ok=True) - if not temp_dir.is_dir(): - raise RuntimeError(f"SQLite temp path is not a directory: {temp_dir}") - - os.environ["SQLITE_TMPDIR"] = str(temp_dir) - os.environ["TMPDIR"] = str(temp_dir) - print(f"SQLite temp dir: {temp_dir}", flush=True) - - -def configure_sqlite_runtime(conn: sqlite3.Connection, *, threads: int) -> None: - conn.execute("PRAGMA temp_store = FILE") - row = conn.execute(f"PRAGMA threads = {threads}").fetchone() - actual_threads = int(row[0]) if row is not None else threads - print(f"SQLite worker threads: {actual_threads}", flush=True) - if actual_threads != threads: - print( - f"Requested {threads} SQLite worker threads, " - f"but SQLite accepted {actual_threads}.", - flush=True, - ) - - -@contextmanager -def sqlite_index_build_mode( - conn: sqlite3.Connection, - *, - journal_mode: str, - dry_run: bool, -): - original_row = conn.execute("PRAGMA journal_mode").fetchone() - original_mode = str(original_row[0]).upper() if original_row else "UNKNOWN" - requested_mode = journal_mode.upper() - active_mode = original_mode - print(f"Original journal_mode: {original_mode}", flush=True) - - if not dry_run and requested_mode != original_mode: - row = conn.execute(f"PRAGMA journal_mode = {requested_mode}").fetchone() - active_mode = str(row[0]).upper() if row else requested_mode - if active_mode != requested_mode: - raise RuntimeError( - f"Could not switch SQLite journal_mode to {requested_mode}; " - f"SQLite reported {active_mode}." - ) - print(f"Build journal_mode: {active_mode}", flush=True) - - try: - yield - except BaseException: - if not dry_run: - conn.rollback() - raise - finally: - if not dry_run and original_mode != "UNKNOWN" and active_mode != original_mode: - row = conn.execute(f"PRAGMA journal_mode = {original_mode}").fetchone() - restored_mode = str(row[0]).upper() if row else "UNKNOWN" - print(f"Restored journal_mode: {restored_mode}", flush=True) - - -def create_composite_index( - conn: sqlite3.Connection, - *, - table: str, - index_name: str, - columns: list[str], - db_path: Path, - temp_dir: Path, - progress_mode: str, - dry_run: bool, -) -> None: - existing_columns = index_columns(conn, index_name) - requested_columns = tuple(columns) - if existing_columns is not None: - if existing_columns != requested_columns: - raise RuntimeError( - f"Index {index_name!r} already exists on columns " - f"{existing_columns}, expected {requested_columns}." - ) - print(f"Composite index already exists: {index_name}({', '.join(columns)})") - return - - columns_sql = ", ".join(quote_identifier(column) for column in columns) - create_sql = ( - f"CREATE INDEX {quote_identifier(index_name)} " - f"ON {quote_identifier(table)}({columns_sql})" - ) - print(f"Creating composite index: {index_name}({', '.join(columns)})", flush=True) - if dry_run: - print(f"Dry run SQL: {create_sql}") - return - - with sqlite_progress( - conn, - f"create {index_name}", - db_path=db_path, - temp_dir=temp_dir, - progress_mode=progress_mode, - ): - conn.execute(create_sql) - conn.commit() - print(f"Created composite index: {index_name}", flush=True) - - -def analyze_table( - conn: sqlite3.Connection, - *, - table: str, - db_path: Path, - temp_dir: Path, - progress_mode: str, - dry_run: bool, -) -> None: - analyze_sql = f"ANALYZE {quote_identifier(table)}" - print(f"Running ANALYZE for {table}", flush=True) - if dry_run: - print(f"Dry run SQL: {analyze_sql}") - return - - with sqlite_progress( - conn, - f"analyze {table}", - db_path=db_path, - temp_dir=temp_dir, - progress_mode=progress_mode, - ): - conn.execute(analyze_sql) - conn.commit() - print(f"Analyzed table: {table}", flush=True) - - -def main() -> int: - args = parse_args() - ensure_valid_args(args) - - print(f"DB: {args.db_path}") - print(f"Table: {args.table}") - print(f"Composite index: {args.index_name}({', '.join(args.columns)})") - - prepare_temp_dir(args.temp_dir, dry_run=args.dry_run) - with closing(connect_database(args.db_path, readonly=args.dry_run)) as conn: - ensure_table_has_columns(conn, args.table, args.columns) - missing_index = index_columns(conn, args.index_name) is None - if missing_index and not args.skip_preflight: - preflight_index_build( - conn, - table=args.table, - db_path=args.db_path, - temp_dir=args.temp_dir, - estimate_index_bytes_per_row=args.estimate_index_bytes_per_row, - ) - if args.dry_run: - print(f"SQLite worker threads: {args.threads} (planned)", flush=True) - else: - configure_sqlite_runtime(conn, threads=args.threads) - with sqlite_index_build_mode( - conn, - journal_mode=args.journal_mode, - dry_run=args.dry_run, - ): - create_composite_index( - conn, - table=args.table, - index_name=args.index_name, - columns=args.columns, - db_path=args.db_path, - temp_dir=args.temp_dir, - progress_mode=args.progress, - dry_run=args.dry_run, - ) - if args.analyze: - analyze_table( - conn, - table=args.table, - db_path=args.db_path, - temp_dir=args.temp_dir, - progress_mode=args.progress, - dry_run=args.dry_run, - ) - - if args.dry_run: - print("Dry run only; no indexes were changed.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/era5_download/modis/pipeline_config.yml b/era5_download/modis/pipeline_config.yml deleted file mode 100644 index d853bdd..0000000 --- a/era5_download/modis/pipeline_config.yml +++ /dev/null @@ -1,47 +0,0 @@ -pipeline: - steps: - - update_igbp_from_c1 - - index_era5 - - transform_modis - include_download: false - dry_run: false - python: null - db_path: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db - -download_modis: - start_date: null - end_date: null - output_dir: experiments/data/raw_modis - products: null - overwrite: false - authenticate: false - -update_igbp_from_c1: - c1_path: experiments/data/raw_modis/201701011200C1.tiff - table: coord_data - only_null: false - write: true - batch_size: 10000 - -index_era5: - table: ec_data - index_name: idx_ec_data_coord_id_timestamp_id - columns: - - coord_id - - timestamp - - id - temp_dir: /project/6100839/luislara/ep/data/sqlite-tmp - journal_mode: DELETE - skip_preflight: false - progress: auto - threads: 8 - analyze: true - dry_run: false - -transform_modis: - input_dir: experiments/data/raw_modis - dates: [] - limit_dates: null - example_count: 10 - overwrite: false - active_ec_coords_only: false diff --git a/era5_download/modis/rebuild_ids_era5.py b/era5_download/modis/rebuild_ids_era5.py deleted file mode 100644 index 1112565..0000000 --- a/era5_download/modis/rebuild_ids_era5.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python3 -"""Rebuild ERA5 ec_data ids once after filtering rows.""" - -from __future__ import annotations - -import argparse -from contextlib import nullcontext -import re -import sqlite3 -from pathlib import Path - -try: - from tqdm.auto import tqdm -except ModuleNotFoundError: - tqdm = None - - -DEFAULT_DB_PATH = Path("/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db") -DEFAULT_TABLE = "ec_data" -DEFAULT_COPY_CHUNK_SIZE = 100_000 - - -class NullProgress: - def update(self, _: int = 1) -> None: - pass - - def set_postfix(self, *args, **kwargs) -> None: - pass - - -def progress_bar(desc: str, total: int, *, unit: str): - if tqdm is None: - return nullcontext(NullProgress()) - return tqdm(total=total, desc=desc, unit=unit, dynamic_ncols=True) - - -def quote_identifier(identifier: str) -> str: - return '"' + identifier.replace('"', '""') + '"' - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Rewrite ec_data once so id values are contiguous after all row " - "filtering steps have finished." - ) - ) - parser.add_argument( - "--db-path", - type=Path, - default=DEFAULT_DB_PATH, - help=f"SQLite database to edit. Default: {DEFAULT_DB_PATH}", - ) - parser.add_argument( - "--table", - default=DEFAULT_TABLE, - help=f"Table whose ids should be rebuilt. Default: {DEFAULT_TABLE}", - ) - parser.add_argument( - "--vacuum", - action="store_true", - help="Run VACUUM after rebuilding ids.", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print how many rows would be copied without changing the DB.", - ) - parser.add_argument( - "--copy-chunk-size", - type=int, - default=DEFAULT_COPY_CHUNK_SIZE, - help=( - "Number of rows to copy per id-rebuild chunk. " - f"Default: {DEFAULT_COPY_CHUNK_SIZE}." - ), - ) - return parser.parse_args() - - -def ensure_valid_args(args: argparse.Namespace) -> None: - if not args.db_path.exists(): - raise SystemExit(f"Database does not exist: {args.db_path}") - if args.copy_chunk_size < 1: - raise SystemExit("--copy-chunk-size must be at least 1.") - - -def table_columns(conn: sqlite3.Connection, table: str) -> list[str]: - rows = conn.execute(f"PRAGMA table_info({quote_identifier(table)})").fetchall() - return [row[1] for row in rows] - - -def count_rows(conn: sqlite3.Connection, table: str) -> int: - return int( - conn.execute(f"SELECT COUNT(*) FROM {quote_identifier(table)}").fetchone()[0] - ) - - -def create_rebuilt_ids_table_sql( - conn: sqlite3.Connection, - table: str, - tmp_table: str, -) -> str: - row = conn.execute( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", - (table,), - ).fetchone() - if row is None or row[0] is None: - raise RuntimeError(f"Could not read CREATE TABLE SQL for {table!r}.") - - create_sql = row[0].strip() - match = re.match( - r"^(CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)(?:\"[^\"]+\"|`[^`]+`|\[[^\]]+\]|\w+)", - create_sql, - flags=re.IGNORECASE, - ) - if match is None: - raise RuntimeError(f"Could not rewrite CREATE TABLE SQL for {table!r}.") - - return f"{match.group(1)}{quote_identifier(tmp_table)}{create_sql[match.end():]}" - - -def copy_and_order_columns( - columns: list[str], - table: str, -) -> tuple[list[str], list[str]]: - if "id" not in columns: - raise RuntimeError(f"Cannot rebuild ids because {table!r} has no id column.") - - copy_columns = [column for column in columns if column != "id"] - if not copy_columns: - raise RuntimeError(f"Cannot rebuild ids for {table!r}; it has no non-id columns.") - - order_columns = [ - column for column in ("coord_id", "timestamp", "id") if column in columns - ] - if not order_columns: - order_columns = ["id"] - - return copy_columns, order_columns - - -def table_index_sqls(conn: sqlite3.Connection, table: str) -> list[str]: - return [ - row[0] - for row in conn.execute( - """ - SELECT sql - FROM sqlite_master - WHERE type = 'index' - AND tbl_name = ? - AND sql IS NOT NULL - ORDER BY name - """, - (table,), - ).fetchall() - ] - - -def copied_position(order_columns: list[str], last_order_values: tuple[object, ...]) -> str: - values_by_column = dict(zip(order_columns, last_order_values)) - parts = [] - for column in ("coord_id", "timestamp", "id"): - if column in values_by_column: - parts.append(f"{column}={values_by_column[column]}") - return ",".join(parts) if parts else str(last_order_values[-1]) - - -def copy_rows_with_progress( - conn: sqlite3.Connection, - table: str, - tmp_table: str, - copy_columns: list[str], - order_columns: list[str], - total_rows: int, - chunk_size: int, -) -> int: - table_sql = quote_identifier(table) - tmp_table_sql = quote_identifier(tmp_table) - copy_columns_sql = ", ".join(quote_identifier(column) for column in copy_columns) - select_columns = copy_columns + [ - column for column in order_columns if column not in copy_columns - ] - select_columns_sql = ", ".join(quote_identifier(column) for column in select_columns) - order_sql = ", ".join(quote_identifier(column) for column in order_columns) - placeholders = ", ".join("?" for _ in copy_columns) - insert_sql = f"INSERT INTO {tmp_table_sql} ({copy_columns_sql}) VALUES ({placeholders})" - order_indexes = [select_columns.index(column) for column in order_columns] - copied_rows = 0 - cursor = conn.execute( - f""" - SELECT {select_columns_sql} - FROM {table_sql} - ORDER BY {order_sql} - """ - ) - - with progress_bar("rebuild_ids_era5 copy", total_rows, unit="row") as progress: - while True: - rows = cursor.fetchmany(chunk_size) - if not rows: - break - - conn.executemany( - insert_sql, - [row[: len(copy_columns)] for row in rows], - ) - copied_rows += len(rows) - last_order_values = tuple(rows[-1][index] for index in order_indexes) - progress.update(len(rows)) - progress.set_postfix( - copied=copied_rows, - at=copied_position(order_columns, last_order_values), - ) - - return copied_rows - - -def rebuild_ids(conn: sqlite3.Connection, table: str, chunk_size: int) -> int: - columns = table_columns(conn, table) - if "id" not in columns: - raise RuntimeError(f"Cannot rebuild ids because {table!r} has no id column.") - - tmp_table = f"{table}__rebuilt_ids" - table_sql = quote_identifier(table) - tmp_table_sql = quote_identifier(tmp_table) - copy_columns, order_columns = copy_and_order_columns(columns, table) - index_sqls = table_index_sqls(conn, table) - - conn.execute(f"DROP TABLE IF EXISTS {tmp_table_sql}") - conn.execute(create_rebuilt_ids_table_sql(conn, table, tmp_table)) - total_rows = count_rows(conn, table) - copied_rows = copy_rows_with_progress( - conn, - table, - tmp_table, - copy_columns, - order_columns, - total_rows, - chunk_size, - ) - conn.execute(f"DROP TABLE {table_sql}") - conn.execute(f"ALTER TABLE {tmp_table_sql} RENAME TO {table_sql}") - for index_sql in index_sqls: - conn.execute(index_sql) - return copied_rows - - -def vacuum_database(conn: sqlite3.Connection) -> None: - row = conn.execute("PRAGMA page_count").fetchone() - total_pages = int(row[0]) if row else 0 - with progress_bar("rebuild_ids_era5 vacuum", total_pages, unit="page") as progress: - conn.execute("VACUUM") - progress.update(total_pages) - - -def main() -> int: - args = parse_args() - args.db_path = args.db_path.expanduser().resolve() - ensure_valid_args(args) - - with sqlite3.connect(args.db_path) as conn: - print(f"DB: {args.db_path}") - print(f"Table: {args.table}") - print(f"Copy chunk size: {args.copy_chunk_size} rows") - - if args.dry_run: - total_rows = count_rows(conn, args.table) - print(f"Rows to copy into rebuilt-id table: {total_rows}") - print("Dry run only; no ids were rebuilt.") - return 0 - - conn.execute("BEGIN") - try: - copied_rows = rebuild_ids(conn, args.table, args.copy_chunk_size) - conn.commit() - except Exception: - conn.rollback() - raise - - if args.vacuum: - with sqlite3.connect(args.db_path) as conn: - vacuum_database(conn) - - print( - f"Rebuilt ids for {args.table}: copied {copied_rows} rows and recreated indexes." - ) - if args.vacuum: - print("Vacuumed database.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/era5_download/modis/pipeline.py b/era5_download/pipeline.py similarity index 70% rename from era5_download/modis/pipeline.py rename to era5_download/pipeline.py index eabdb35..a9ca05d 100644 --- a/era5_download/modis/pipeline.py +++ b/era5_download/pipeline.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run selected EcoPerceiver MODIS/ERA5 pipeline steps.""" +"""Run the unified EcoPerceiver ERA5/MODIS data pipeline.""" from __future__ import annotations @@ -18,12 +18,14 @@ yaml = None -REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent DEFAULT_CONFIG_PATH = SCRIPT_DIR / "pipeline_config.yml" DEFAULT_STEPS = ( - "update_igbp_from_c1", - "index_era5", + "download_era5", + "process_era5", + "download_modis", + "assign_igbp_from_c1", "transform_modis", ) @@ -35,17 +37,21 @@ class PipelineStep: STEPS = { + "download_era5": PipelineStep( + name="download_era5", + description="Download ERA5 NetCDF chunks from CDS.", + ), + "process_era5": PipelineStep( + name="process_era5", + description="Convert ERA5 NetCDF chunks to the EcoPerceiver SQLite DB.", + ), "download_modis": PipelineStep( name="download_modis", description="Download raw MODIS GeoTIFFs from Earth Engine.", ), - "update_igbp_from_c1": PipelineStep( - name="update_igbp_from_c1", - description="Update ERA5 coord_data.igbp from a MODIS C1 land-cover raster.", - ), - "index_era5": PipelineStep( - name="index_era5", - description="Create persistent ec_data indexes for ERA5 eval and MODIS lookup.", + "assign_igbp_from_c1": PipelineStep( + name="assign_igbp_from_c1", + description="Assign coord_data.igbp from a MODIS C1 land-cover raster.", ), "transform_modis": PipelineStep( name="transform_modis", @@ -147,10 +153,10 @@ def parse_args() -> argparse.Namespace: config, resolved_config_path = load_config(config_args.config_path) pipeline_config = config_section(config, "pipeline") - download_config = config_section(config, "download_modis") - update_igbp_config = config_section(config, "update_igbp_from_c1") + path_config = config_section(config, "paths") + download_modis_config = config_section(config, "download_modis") + assign_igbp_config = config_section(config, "assign_igbp_from_c1") transform_config = config_section(config, "transform_modis") - index_config = config_section(config, "index_era5") default_steps = config_list( pipeline_config.get("steps"), @@ -166,9 +172,7 @@ def parse_args() -> argparse.Namespace: ) parser = argparse.ArgumentParser( parents=[config_parser], - description=( - "Run selected pieces of the EcoPerceiver ERA5/MODIS pipeline." - ), + description="Run selected pieces of the unified ERA5/MODIS pipeline.", ) parser.set_defaults(config_path=resolved_config_path) parser.add_argument( @@ -183,16 +187,6 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Print available pipeline steps and exit.", ) - parser.add_argument( - "--include-download", - action=argparse.BooleanOptionalAction, - default=bool_config( - pipeline_config.get("include_download"), - default=False, - field_name="pipeline.include_download", - ), - help="Prepend download_modis to the selected steps if it is not already present.", - ) parser.add_argument( "--dry-run", action=argparse.BooleanOptionalAction, @@ -212,15 +206,33 @@ def parse_args() -> argparse.Namespace: "--db-path", type=Path, default=resolve_repo_path( - pipeline_config.get("db_path", "experiments/data/poc_era5.db") + path_config.get( + "db_path", + pipeline_config.get("db_path", "experiments/data/era5.db"), + ) ), help="SQLite DB used by ERA5/MODIS pipeline steps.", ) + parser.add_argument( + "--limit-era5-groups", + type=int, + default=None, + help="Limit ERA5 request/group count for smoke tests.", + ) + parser.add_argument( + "--overwrite-era5-downloads", + action="store_true", + help="Overwrite existing downloaded ERA5 chunks.", + ) + parser.add_argument( + "--overwrite-era5-db", + action="store_true", + help="Recreate the ERA5 SQLite DB during process_era5.", + ) - add_download_args(parser, download_config) - add_update_igbp_args(parser, update_igbp_config) + add_download_modis_args(parser, download_modis_config) + add_assign_igbp_args(parser, assign_igbp_config) add_transform_args(parser, transform_config) - add_index_era5_args(parser, index_config) args = parser.parse_args() if args.modis_dates is None: @@ -229,7 +241,7 @@ def parse_args() -> argparse.Namespace: return args -def add_download_args( +def add_download_modis_args( parser: argparse.ArgumentParser, config: dict[str, Any], ) -> None: @@ -237,12 +249,12 @@ def add_download_args( group.add_argument( "--download-start-date", default=config.get("start_date"), - help="Optional download start date in YYYY-MM-DD format.", + help="Optional MODIS download start date in YYYY-MM-DD format.", ) group.add_argument( "--download-end-date", default=config.get("end_date"), - help="Optional download end date in YYYY-MM-DD format.", + help="Optional MODIS download end date in YYYY-MM-DD format.", ) group.add_argument( "--download-output-dir", @@ -259,6 +271,12 @@ def add_download_args( ), help="Optional MODIS products to download, e.g. A4 A2 C1.", ) + group.add_argument( + "--download-max-workers", + type=int, + default=config.get("max_workers"), + help="Concurrent MODIS tile downloads per image.", + ) group.add_argument( "--download-overwrite", action=argparse.BooleanOptionalAction, @@ -281,18 +299,18 @@ def add_download_args( ) -def add_update_igbp_args( +def add_assign_igbp_args( parser: argparse.ArgumentParser, config: dict[str, Any], ) -> None: - group = parser.add_argument_group("update_igbp_from_c1 options") + group = parser.add_argument_group("assign_igbp_from_c1 options") group.add_argument( "--igbp-c1-path", type=Path, default=resolve_repo_path( - config.get("c1_path", "experiments/data/raw_modis/201801011200C1.tiff") + config.get("c1_path", "experiments/data/raw_modis/201701011200C1.tiff") ), - help="MODIS MCD12C1 GeoTIFF used to update coord_data.igbp.", + help="MODIS MCD12C1 GeoTIFF used to assign coord_data.igbp.", ) group.add_argument( "--igbp-table", @@ -305,9 +323,9 @@ def add_update_igbp_args( default=bool_config( config.get("only_null"), default=False, - field_name="update_igbp_from_c1.only_null", + field_name="assign_igbp_from_c1.only_null", ), - help="Only update coord_data rows where igbp is NULL.", + help="Only assign coord_data rows where igbp is NULL.", ) group.add_argument( "--igbp-write", @@ -315,15 +333,15 @@ def add_update_igbp_args( default=bool_config( config.get("write"), default=True, - field_name="update_igbp_from_c1.write", + field_name="assign_igbp_from_c1.write", ), - help="Apply IGBP updates. Use --no-igbp-write to run the updater read-only.", + help="Apply IGBP assignments. Use --no-igbp-write to run read-only.", ) group.add_argument( "--igbp-batch-size", type=int, default=config.get("batch_size", 10_000), - help="SQLite update batch size for update_igbp_from_c1.", + help="SQLite assignment batch size for assign_igbp_from_c1.", ) @@ -382,87 +400,6 @@ def add_transform_args( ) -def add_index_era5_args( - parser: argparse.ArgumentParser, - config: dict[str, Any], -) -> None: - group = parser.add_argument_group("index_era5 options") - group.add_argument( - "--era5-index-table", - default=config.get("table", "ec_data"), - help="ERA5 table to index.", - ) - group.add_argument( - "--era5-index-name", - default=config.get("index_name", "idx_ec_data_coord_id_timestamp_id"), - help="Composite index name used by ERA5 eval.", - ) - group.add_argument( - "--era5-index-columns", - nargs="+", - default=config_list( - config.get("columns"), - default=["coord_id", "timestamp", "id"], - field_name="index_era5.columns", - ), - help="Columns for the composite ERA5 eval index.", - ) - group.add_argument( - "--era5-index-analyze", - action=argparse.BooleanOptionalAction, - default=bool_config( - config.get("analyze"), - default=True, - field_name="index_era5.analyze", - ), - help="Run ANALYZE after creating the index.", - ) - group.add_argument( - "--era5-index-dry-run", - action=argparse.BooleanOptionalAction, - default=bool_config( - config.get("dry_run"), - default=False, - field_name="index_era5.dry_run", - ), - help="Run the index_era5 step in dry-run mode.", - ) - group.add_argument( - "--era5-index-temp-dir", - type=Path, - default=resolve_repo_path(config.get("temp_dir")), - help="SQLite temporary sorter directory for the ERA5 index build.", - ) - group.add_argument( - "--era5-index-journal-mode", - choices=("DELETE", "TRUNCATE", "PERSIST", "WAL"), - default=config.get("journal_mode", "DELETE"), - help="SQLite journal mode to use while building the ERA5 index.", - ) - group.add_argument( - "--era5-index-skip-preflight", - action=argparse.BooleanOptionalAction, - default=bool_config( - config.get("skip_preflight"), - default=False, - field_name="index_era5.skip_preflight", - ), - help="Skip disk/page-count estimates before creating the ERA5 index.", - ) - group.add_argument( - "--era5-index-progress", - choices=("auto", "tqdm", "heartbeat", "none"), - default=config.get("progress", "auto"), - help="Progress display mode for the ERA5 index step.", - ) - group.add_argument( - "--era5-index-threads", - type=int, - default=config.get("threads", 8), - help="SQLite worker threads for the ERA5 index step.", - ) - - def print_available_steps(default_steps: list[str]) -> None: print(f"Configured default steps: {' '.join(default_steps)}") print() @@ -473,17 +410,49 @@ def print_available_steps(default_steps: list[str]) -> None: def command_for_step(step: str, args: argparse.Namespace) -> list[str]: + if step == "download_era5": + return download_era5_command(args) + if step == "process_era5": + return process_era5_command(args) if step == "download_modis": return download_modis_command(args) - if step == "update_igbp_from_c1": - return update_igbp_from_c1_command(args) + if step == "assign_igbp_from_c1": + return assign_igbp_from_c1_command(args) if step == "transform_modis": return transform_modis_command(args) - if step == "index_era5": - return index_era5_command(args) raise ValueError(f"Unsupported pipeline step: {step}") +def download_era5_command(args: argparse.Namespace) -> list[str]: + command = [ + args.python, + str(SCRIPT_DIR / "download_era5.py"), + "--config", + str(args.config_path), + "--download-only", + ] + if args.limit_era5_groups is not None: + command.extend(["--limit-groups", str(args.limit_era5_groups)]) + if args.overwrite_era5_downloads: + command.append("--overwrite-downloads") + return command + + +def process_era5_command(args: argparse.Namespace) -> list[str]: + command = [ + args.python, + str(SCRIPT_DIR / "download_era5.py"), + "--config", + str(args.config_path), + "--process-only", + ] + if args.limit_era5_groups is not None: + command.extend(["--limit-groups", str(args.limit_era5_groups)]) + if args.overwrite_era5_db: + command.append("--overwrite-db") + return command + + def download_modis_command(args: argparse.Namespace) -> list[str]: command = [ args.python, @@ -501,6 +470,8 @@ def download_modis_command(args: argparse.Namespace) -> list[str]: command.extend(["--output-dir", str(args.download_output_dir)]) if args.download_products: command.extend(["--products", *args.download_products]) + if args.download_max_workers is not None: + command.extend(["--max-workers", str(args.download_max_workers)]) if args.download_overwrite: command.append("--overwrite") if args.download_authenticate: @@ -508,10 +479,10 @@ def download_modis_command(args: argparse.Namespace) -> list[str]: return command -def update_igbp_from_c1_command(args: argparse.Namespace) -> list[str]: +def assign_igbp_from_c1_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(SCRIPT_DIR / "update_igbp_from_c1.py"), + str(SCRIPT_DIR / "assign_igbp_from_c1.py"), "--db-path", str(args.db_path), "--c1-path", @@ -550,33 +521,6 @@ def transform_modis_command(args: argparse.Namespace) -> list[str]: return command -def index_era5_command(args: argparse.Namespace) -> list[str]: - command = [ - args.python, - str(SCRIPT_DIR / "index_era5.py"), - "--db-path", - str(args.db_path), - "--table", - args.era5_index_table, - "--index-name", - args.era5_index_name, - "--columns", - *args.era5_index_columns, - ] - if not args.era5_index_analyze: - command.append("--no-analyze") - if args.era5_index_dry_run: - command.append("--dry-run") - if args.era5_index_temp_dir is not None: - command.extend(["--temp-dir", str(args.era5_index_temp_dir)]) - command.extend(["--journal-mode", args.era5_index_journal_mode]) - command.extend(["--progress", args.era5_index_progress]) - command.extend(["--threads", str(args.era5_index_threads)]) - if args.era5_index_skip_preflight: - command.append("--skip-preflight") - return command - - def run_command(command: list[str], dry_run: bool) -> None: print(f"$ {shlex.join(command)}", flush=True) if dry_run: @@ -600,8 +544,6 @@ def main() -> int: return 0 steps = list(args.steps) - if args.include_download and "download_modis" not in steps: - steps.insert(0, "download_modis") print(f"Config: {args.config_path}") print(f"Selected pipeline steps: {' '.join(steps)}") for step in steps: diff --git a/era5_download/modis/pipeline.sh b/era5_download/pipeline.sh similarity index 56% rename from era5_download/modis/pipeline.sh rename to era5_download/pipeline.sh index 3ce4d19..4e4abdb 100755 --- a/era5_download/modis/pipeline.sh +++ b/era5_download/pipeline.sh @@ -4,21 +4,24 @@ #SBATCH --mem=128G #SBATCH --cpus-per-task=8 #SBATCH --time=24:00:00 -#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/modis/logs/pipeline_%j.out -#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/modis/logs/pipeline_%j.error -#SBATCH --job-name=modis-pipeline +#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/logs/pipeline_%j.out +#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/logs/pipeline_%j.error +#SBATCH --job-name=era5-download #SBATCH --account=aip-pal set -euo pipefail source "$SCRATCH/env/ecoperceiver/bin/activate" cd "$HOME/links/scratch/EcoPerceiver" -mkdir -p era5_download/modis/logs +mkdir -p era5_download/logs export PYTHONPATH=. export PYTHONUNBUFFERED=1 export OMP_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" +export MKL_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" +export OPENBLAS_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" +export NUMEXPR_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" -python -u era5_download/modis/pipeline.py \ - --config-path era5_download/modis/pipeline_config.yml \ +python -u era5_download/pipeline.py \ + --config-path era5_download/pipeline_config.yml \ "$@" diff --git a/era5_download/era5/config.yml b/era5_download/pipeline_config.yml similarity index 71% rename from era5_download/era5/config.yml rename to era5_download/pipeline_config.yml index dd8402f..153889f 100644 --- a/era5_download/era5/config.yml +++ b/era5_download/pipeline_config.yml @@ -1,3 +1,13 @@ +pipeline: + steps: + - download_era5 + - process_era5 + # - download_modis + # - assign_igbp_from_c1 + # - transform_modis + dry_run: false + python: null + years: - 2016 - 2017 @@ -7,6 +17,7 @@ paths: db_path: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_2016_2017.db netcdf_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_data zip_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_zip + lsm_path: experiments/data/lsm.nc download: enabled: true @@ -57,8 +68,6 @@ processing: # "local" stores wall-clock timestamps per coordinate. Half-hour offsets # produce timestamps such as YYYYMMDDHH3000. timestamp_policy: local - # Use timezonefinder/IANA zones when installed. If a coordinate has no - # zone, fall back to longitude-based quarter-hour offsets. method: timezonefinder require_timezonefinder: true fallback: longitude_quarter_hour @@ -69,12 +78,30 @@ processing: land_sea_mask: enabled: true - path: experiments/data/lsm.nc threshold: 0.5 include_land_neighbors: true allow_missing: false - igbp: - enabled: true - raster_path: experiments/data/raw_modis/201701011200C1.tiff - allow_missing: false +download_modis: + start_date: "2016-01-01" + end_date: "2017-12-31" + output_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/raw_modis + products: null + max_workers: 8 + overwrite: false + authenticate: false + +assign_igbp_from_c1: + c1_path: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/raw_modis/201701011200C1.tiff + table: coord_data + only_null: false + write: true + batch_size: 10000 + +transform_modis: + input_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/raw_modis + dates: [] + limit_dates: null + example_count: 10 + overwrite: false + active_ec_coords_only: false diff --git a/era5_download/modis/transform_modis.py b/era5_download/transform_modis.py similarity index 99% rename from era5_download/modis/transform_modis.py rename to era5_download/transform_modis.py index dbb882c..5207e04 100644 --- a/era5_download/modis/transform_modis.py +++ b/era5_download/transform_modis.py @@ -48,7 +48,7 @@ except ModuleNotFoundError: tqdm = None -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_INPUT_DIR = REPO_ROOT / "experiments" / "data" / "raw_modis" DEFAULT_DB_PATH = Path("/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db") CELL_SIZE_DEGREES = 0.25 @@ -351,8 +351,8 @@ def load_coord_lookup( if not index_has_leading_column(conn, table="ec_data", column="coord_id"): raise RuntimeError( "`--active-ec-coords-only` requires an ec_data index whose first " - "column is coord_id. Run `era5_download/modis/index_era5.py` first to " - "create idx_ec_data_coord_id_timestamp_id, or pass " + "column is coord_id. Run the ERA5 process stage first so " + "download_era5.py creates idx_ec_data_coord_id_timestamp_id, or pass " "`--no-active-ec-coords-only`." ) diff --git a/era5_download/modis/plot_igbp_map.py b/scripts/plot_igbp_map.py old mode 100755 new mode 100644 similarity index 99% rename from era5_download/modis/plot_igbp_map.py rename to scripts/plot_igbp_map.py index 7e8785e..dcd26c0 --- a/era5_download/modis/plot_igbp_map.py +++ b/scripts/plot_igbp_map.py @@ -26,7 +26,7 @@ ) from exc -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT_DIR = Path(__file__).resolve().parent DEFAULT_DB_PATH = Path("/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db") DEFAULT_OUTPUT_PATH = SCRIPT_DIR / "igbp_coord_map.png" From 1b5fcfb1fbde3a352e97134d5dc9a412a36ce279 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Thu, 18 Jun 2026 14:22:52 -0400 Subject: [PATCH 03/14] dayli to montly download_era5 --- era5_download/download_era5.py | 138 ++++++++++++++++++++++++------ era5_download/pipeline.py | 10 +++ era5_download/pipeline_config.yml | 5 +- 3 files changed, 122 insertions(+), 31 deletions(-) diff --git a/era5_download/download_era5.py b/era5_download/download_era5.py index 77418fc..67618d5 100644 --- a/era5_download/download_era5.py +++ b/era5_download/download_era5.py @@ -15,6 +15,7 @@ import argparse import calendar from collections.abc import Iterable +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import datetime, timedelta, timezone import json @@ -24,6 +25,7 @@ import shutil import sqlite3 import sys +import threading from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import zipfile @@ -383,10 +385,18 @@ def parse_args() -> argparse.Namespace: default=None, help="Limit request/group count for smoke tests.", ) + parser.add_argument( + "--max-workers", + type=int, + default=None, + help="Override download.max_workers for concurrent CDS downloads.", + ) args = parser.parse_args() if args.download_only and args.process_only: parser.error("--download-only and --process-only are mutually exclusive.") + if args.max_workers is not None and args.max_workers < 1: + parser.error("--max-workers must be at least 1.") return args @@ -587,6 +597,20 @@ def extract_zip(zip_path: Path, output_dir: Path) -> None: archive.extractall(output_dir) +def configured_download_workers(config: dict[str, Any], args: argparse.Namespace) -> int: + download_config = section(config, "download") + value = args.max_workers + if value is None: + value = download_config.get("max_workers", 1) + try: + workers = int(value) + except (TypeError, ValueError) as exc: + raise SystemExit("download.max_workers must be an integer >= 1.") from exc + if workers < 1: + raise SystemExit("download.max_workers must be an integer >= 1.") + return workers + + def download_groups(config: dict[str, Any], args: argparse.Namespace) -> None: paths = section(config, "paths") download_config = section(config, "download") @@ -601,19 +625,20 @@ def download_groups(config: dict[str, Any], args: argparse.Namespace) -> None: if args.limit_groups is not None: groups = groups[: args.limit_groups] overwrite = bool(download_config.get("overwrite", False) or args.overwrite_downloads) + max_workers = configured_download_workers(config, args) print(f"Planned ERA5 CDS request groups: {len(groups)}") for index, group in enumerate(groups[:5], start=1): print(f" {index:>3}: {group.stem} area={group.area}") if len(groups) > 5: print(f" ... {len(groups) - 5} more") + print(f"ERA5 CDS download workers: {max_workers}") if args.dry_run: return ensure_dependencies("cdsapi") zip_dir.mkdir(parents=True, exist_ok=True) netcdf_dir.mkdir(parents=True, exist_ok=True) - client = cdsapi.Client(wait_until_complete=True, delete=False) dataset = download_config.get("dataset", "reanalysis-era5-single-levels") progress = ( tqdm( @@ -627,46 +652,103 @@ def download_groups(config: dict[str, Any], args: argparse.Namespace) -> None: ) completed = 0 skipped = 0 + pending: list[RequestGroup] = [] + log_lock = threading.Lock() + thread_local = threading.local() def log(message: str) -> None: + with log_lock: + if progress is not None: + progress.write(message) + else: + print(message) + + def update_progress() -> None: if progress is not None: - progress.write(message) - else: - print(message) + progress.set_postfix(done=completed, skipped=skipped, refresh=False) + + def get_client(): + client = getattr(thread_local, "client", None) + if client is None: + client = cdsapi.Client(wait_until_complete=True, delete=False) + thread_local.client = client + return client + + def download_one(group: RequestGroup) -> None: + client = get_client() + group_dir = netcdf_dir / group.stem + sentinel = group_dir / ".complete" + zip_path = zip_dir / f"{group.stem}.zip" + if overwrite and group_dir.exists(): + shutil.rmtree(group_dir) + if overwrite and zip_path.exists(): + zip_path.unlink() + + payload = cds_request_payload(config, group) + log(f"[request] {group.stem}") + result = client.retrieve(dataset, payload) + log(f"[download] {zip_path}") + result.download(str(zip_path)) + log(f"[extract] {group_dir}") + extract_zip(zip_path, group_dir) + sentinel.write_text("complete\n", encoding="utf-8") + zip_path.unlink(missing_ok=True) try: for group in groups: - if progress is not None: - progress.set_postfix_str(group.stem, refresh=False) - group_dir = netcdf_dir / group.stem sentinel = group_dir / ".complete" - zip_path = zip_dir / f"{group.stem}.zip" if sentinel.exists() and not overwrite: skipped += 1 log(f"[skip] {group.stem}") if progress is not None: progress.update(1) - progress.set_postfix(done=completed, skipped=skipped, refresh=False) - continue - if overwrite and group_dir.exists(): - shutil.rmtree(group_dir) - if overwrite and zip_path.exists(): - zip_path.unlink() - - payload = cds_request_payload(config, group) - log(f"[request] {group.stem}") - result = client.retrieve(dataset, payload) - log(f"[download] {zip_path}") - result.download(str(zip_path)) - log(f"[extract] {group_dir}") - extract_zip(zip_path, group_dir) - sentinel.write_text("complete\n", encoding="utf-8") - zip_path.unlink(missing_ok=True) - completed += 1 - if progress is not None: - progress.update(1) - progress.set_postfix(done=completed, skipped=skipped, refresh=False) + update_progress() + else: + pending.append(group) + + if not pending: + return + + effective_workers = min(max_workers, len(pending)) + if effective_workers == 1: + for group in pending: + if progress is not None: + progress.set_postfix_str(group.stem, refresh=False) + download_one(group) + completed += 1 + if progress is not None: + progress.update(1) + update_progress() + return + + pending_iter = iter(pending) + with ThreadPoolExecutor(max_workers=effective_workers) as executor: + future_to_group = {} + for _ in range(effective_workers): + group = next(pending_iter, None) + if group is None: + break + future_to_group[executor.submit(download_one, group)] = group + + while future_to_group: + for future in as_completed(future_to_group): + group = future_to_group.pop(future) + if progress is not None: + progress.set_postfix_str(group.stem, refresh=False) + try: + future.result() + except Exception as exc: + raise RuntimeError(f"ERA5 download failed for {group.stem}") from exc + completed += 1 + if progress is not None: + progress.update(1) + update_progress() + + next_group = next(pending_iter, None) + if next_group is not None: + future_to_group[executor.submit(download_one, next_group)] = next_group + break finally: if progress is not None: progress.close() diff --git a/era5_download/pipeline.py b/era5_download/pipeline.py index a9ca05d..a4d21e0 100644 --- a/era5_download/pipeline.py +++ b/era5_download/pipeline.py @@ -219,6 +219,12 @@ def parse_args() -> argparse.Namespace: default=None, help="Limit ERA5 request/group count for smoke tests.", ) + parser.add_argument( + "--era5-max-workers", + type=int, + default=None, + help="Override download.max_workers for concurrent ERA5 CDS downloads.", + ) parser.add_argument( "--overwrite-era5-downloads", action="store_true", @@ -237,6 +243,8 @@ def parse_args() -> argparse.Namespace: if args.modis_dates is None: args.modis_dates = default_modis_dates + if args.era5_max_workers is not None and args.era5_max_workers < 1: + parser.error("--era5-max-workers must be at least 1.") validate_steps(list(args.steps)) return args @@ -433,6 +441,8 @@ def download_era5_command(args: argparse.Namespace) -> list[str]: ] if args.limit_era5_groups is not None: command.extend(["--limit-groups", str(args.limit_era5_groups)]) + if args.era5_max_workers is not None: + command.extend(["--max-workers", str(args.era5_max_workers)]) if args.overwrite_era5_downloads: command.append("--overwrite-downloads") return command diff --git a/era5_download/pipeline_config.yml b/era5_download/pipeline_config.yml index 153889f..b1c9457 100644 --- a/era5_download/pipeline_config.yml +++ b/era5_download/pipeline_config.yml @@ -27,9 +27,8 @@ download: download_format: zip # ERA5/CDS area order is north, west, south, east. bbox: [90, -180, -90, 180] - # CarbonCast forces daily groups for large areas. Latitude bands keep each - # request smaller for global exports. - temporal_chunk: daily + temporal_chunk: monthly + max_workers: 4 latitude_band_degrees: 30 overwrite: false variables: From 1a07faf08a16302a3f5ce059efb7d1d426e98c4f Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Tue, 23 Jun 2026 19:26:39 -0400 Subject: [PATCH 04/14] insert improved --- era5_download/download_era5.py | 178 +++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 30 deletions(-) diff --git a/era5_download/download_era5.py b/era5_download/download_era5.py index 67618d5..515f33b 100644 --- a/era5_download/download_era5.py +++ b/era5_download/download_era5.py @@ -1082,12 +1082,28 @@ def nullable_float(value) -> float | None: return value +@dataclass(frozen=True) +class Era5GroupGrid: + lat: np.ndarray + lon: np.ndarray + land_indices: np.ndarray + spatial_shape: tuple[int, int] + + +@dataclass(frozen=True) +class Era5TimeBlock: + variables: dict[str, np.ndarray] + length: int + + class Era5PostProcessor: def __init__(self, config: dict[str, Any], conn: sqlite3.Connection): processing_config = section(config, "processing") self.config = config self.conn = conn self.batch_size = int(processing_config.get("batch_size", 50_000)) + time_block_size = processing_config.get("time_block_size") + self.time_block_size = int(time_block_size) if time_block_size else None self.xarray_engine = processing_config.get("xarray_engine") self.timezone_resolver = TimezoneResolver(config) self.land_mask = LandSeaMask(config) @@ -1112,10 +1128,29 @@ def process_groups(self, netcdf_dir: Path, limit_groups: int | None = None) -> i total_rows = 0 print(f"NetCDF groups to process: {len(group_dirs)}") - for group_dir in group_dirs: + progress = ( + tqdm( + group_dirs, + desc="ERA5 processing", + unit="group", + dynamic_ncols=True, + file=sys.stdout, + ) + if tqdm is not None + else group_dirs + ) + for group_dir in progress: rows = self.process_group(group_dir) total_rows += rows - print(f"[inserted] {rows:,} rows from {group_dir}") + if tqdm is not None: + progress.set_postfix( + last_rows=f"{rows:,}", + total_rows=f"{total_rows:,}", + refresh=False, + ) + progress.write(f"[inserted] {rows:,} rows from {group_dir}") + else: + print(f"[inserted] {rows:,} rows from {group_dir}") return total_rows def process_group(self, group_dir: Path) -> int: @@ -1129,21 +1164,123 @@ def process_group(self, group_dir: Path) -> int: if "valid_time" not in ds.coords: raise RuntimeError(f"Dataset has no valid_time coordinate: {group_dir}") inserted = 0 + group_grid = self.prepare_group_grid(ds) valid_times = pd.to_datetime(ds["valid_time"].values) - for time_index, valid_time in enumerate(valid_times): - frame = ds.isel(valid_time=time_index).to_dataframe().reset_index() - inserted += self.process_frame(frame, pd.Timestamp(valid_time)) + time_block_size = self.infer_time_block_size(ds, len(valid_times)) + for block_start in range(0, len(valid_times), time_block_size): + block_end = min(block_start + time_block_size, len(valid_times)) + block = self.land_block_from_dataset(ds, group_grid, block_start, block_end) + for block_offset, valid_time in enumerate(valid_times[block_start:block_end]): + frame = self.land_frame_from_block(group_grid, block, block_offset) + inserted += self.process_land_frame(frame, pd.Timestamp(valid_time)) self.conn.commit() return inserted finally: for ds in datasets: ds.close() - def process_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> int: - df = standardize_dataframe(df) - if df.empty: - return 0 - df = self.land_mask.filter(df) + def prepare_group_grid(self, ds) -> Era5GroupGrid: + if "latitude" not in ds.coords or "longitude" not in ds.coords: + raise RuntimeError("ERA5 dataset is missing latitude/longitude coordinates.") + + lats = np.asarray(ds["latitude"].values, dtype=float) + lons = normalize_longitudes(np.asarray(ds["longitude"].values, dtype=float)) + lat_flat = np.repeat(lats, len(lons)) + lon_flat = np.tile(lons, len(lats)) + coord_frame = pd.DataFrame({"lat": lat_flat, "lon": lon_flat}) + land_frame = self.land_mask.filter(coord_frame) + land_indices = land_frame.index.to_numpy(dtype=np.int64) + return Era5GroupGrid( + lat=lat_flat[land_indices], + lon=lon_flat[land_indices], + land_indices=land_indices, + spatial_shape=(len(lats), len(lons)), + ) + + def infer_time_block_size(self, ds, valid_time_count: int) -> int: + if self.time_block_size is not None: + return max(1, min(int(self.time_block_size), valid_time_count)) + for data_array in ds.data_vars.values(): + if "valid_time" not in data_array.dims: + continue + preferred_chunks = data_array.encoding.get("preferred_chunks") or {} + chunk_size = preferred_chunks.get("valid_time") + if chunk_size is None: + chunksizes = data_array.encoding.get("chunksizes") + if chunksizes: + chunk_size = chunksizes[data_array.dims.index("valid_time")] + if chunk_size: + return max(1, min(int(chunk_size), valid_time_count)) + return min(24, valid_time_count) + + def land_block_from_dataset( + self, + ds, + group_grid: Era5GroupGrid, + block_start: int, + block_end: int, + ) -> Era5TimeBlock: + block_length = block_end - block_start + variables: dict[str, np.ndarray] = {} + spatial_dims = {"latitude", "longitude"} + allowed_dims = {"valid_time", *spatial_dims} + + for name, data_array in ds.data_vars.items(): + if not spatial_dims.issubset(data_array.dims): + continue + values = data_array + if "valid_time" in values.dims: + values = values.isel(valid_time=slice(block_start, block_end)) + for dim in tuple(values.dims): + if dim in allowed_dims: + continue + if values.sizes[dim] != 1: + raise RuntimeError( + f"Cannot convert ERA5 variable {name!r}; unexpected dimension " + f"{dim!r} has size {values.sizes[dim]}." + ) + values = values.isel({dim: 0}, drop=True) + + if "valid_time" in values.dims: + arr = np.asarray(values.transpose("valid_time", "latitude", "longitude").values) + expected_shape = (block_length, *group_grid.spatial_shape) + if arr.shape != expected_shape: + raise RuntimeError( + f"ERA5 variable {name!r} has shape {arr.shape}, expected " + f"{expected_shape}." + ) + variables[name] = arr.reshape(block_length, -1)[:, group_grid.land_indices] + else: + arr = np.asarray(values.transpose("latitude", "longitude").values) + if arr.shape != group_grid.spatial_shape: + raise RuntimeError( + f"ERA5 variable {name!r} has shape {arr.shape}, expected " + f"{group_grid.spatial_shape}." + ) + land_values = arr.reshape(-1)[group_grid.land_indices] + variables[name] = np.broadcast_to( + land_values, + (block_length, len(group_grid.land_indices)), + ) + return Era5TimeBlock(variables=variables, length=block_length) + + def land_frame_from_block( + self, + group_grid: Era5GroupGrid, + block: Era5TimeBlock, + block_offset: int, + ) -> "pd.DataFrame": + data: dict[str, np.ndarray] = { + "lat": group_grid.lat, + "lon": group_grid.lon, + } + if block_offset < 0 or block_offset >= block.length: + raise IndexError(f"Block offset {block_offset} is outside block length {block.length}.") + for name, values in block.variables.items(): + data[name] = values[block_offset] + return pd.DataFrame(data, copy=False) + + def process_land_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> int: if df.empty: return 0 df = self.add_predictors(df) @@ -1230,25 +1367,6 @@ def standardize_dataset(ds): return ds.rename({**rename_map, **coord_renames}) -def standardize_dataframe(df: "pd.DataFrame") -> "pd.DataFrame": - rename_map = { - "latitude": "lat", - "longitude": "lon", - "valid_time": "timestamp_utc", - "time": "timestamp_utc", - } - df = df.rename(columns={old: new for old, new in rename_map.items() if old in df.columns}) - for column in ["number", "expver", "region_id", "spatial_ref"]: - if column in df.columns: - df = df.drop(columns=column) - if "lat" not in df.columns or "lon" not in df.columns: - raise RuntimeError("ERA5 frame is missing latitude/longitude columns.") - df = df.dropna(subset=["lat", "lon"]).copy() - df["lat"] = df["lat"].astype(float) - df["lon"] = normalize_longitudes(df["lon"]) - return df - - def minmax_normalization(df: "pd.DataFrame") -> "pd.DataFrame": for predictor in EC_PREDICTORS: if predictor not in df.columns: @@ -1277,7 +1395,7 @@ def minmax_normalization(df: "pd.DataFrame") -> "pd.DataFrame": def configure_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: processing_config = section(config, "processing") - conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA journal_mode = DELETE") conn.execute("PRAGMA synchronous = NORMAL") conn.execute("PRAGMA temp_store = FILE") conn.execute(f"PRAGMA threads = {int(processing_config.get('sqlite_threads', 8))}") From cab09340c4e00fdaeb708098d8d54e2c2e713825 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Wed, 24 Jun 2026 09:17:17 -0400 Subject: [PATCH 05/14] eval netcdf changed --- eval/merge_era5_shards.py | 313 ++++++++++++++++++++++++++++---- scripts/viz_netcdf_structure.py | 29 ++- 2 files changed, 300 insertions(+), 42 deletions(-) diff --git a/eval/merge_era5_shards.py b/eval/merge_era5_shards.py index d81e1a3..59eee6b 100644 --- a/eval/merge_era5_shards.py +++ b/eval/merge_era5_shards.py @@ -4,11 +4,35 @@ import shutil import subprocess import os +from datetime import datetime, timezone from pathlib import Path INTERNAL_ORDER_COLUMN = "__sample_order" BASE_OUTPUT_COLUMNS = ("lat", "lon", "igbp", "timestamp") DEFAULT_SORT_COLUMNS = ("lat", "lon", "timestamp") +ERA5_TIME_DIM = "valid_time" +ERA5_SPATIAL_DIMS = ("latitude", "longitude") +ERA5_CUBE_DIMS = (ERA5_TIME_DIM, *ERA5_SPATIAL_DIMS) +ERA5_COORDINATES_ATTR = "number valid_time latitude longitude expver" +COORDINATE_DECIMALS = 6 +REGULAR_GRID_MIN_COVERAGE = 0.9 +ERA5_CHUNK_TARGETS = (186, 31, 360) +ERA5_GLOBAL_ATTRS = { + "GRIB_centre": "ecmf", + "GRIB_centreDescription": "European Centre for Medium-Range Weather Forecasts", + "GRIB_subCentre": 0, + "Conventions": "CF-1.7", + "institution": "European Centre for Medium-Range Weather Forecasts", +} +PREDICTION_TARGET_METADATA = { + "NEE": ("Predicted net ecosystem exchange", "umol CO2 m-2 s-1"), + "GPP_DT": ("Predicted daytime gross primary productivity", "umol CO2 m-2 s-1"), + "GPP_NT": ("Predicted nighttime gross primary productivity", "umol CO2 m-2 s-1"), + "RECO_DT": ("Predicted daytime ecosystem respiration", "umol CO2 m-2 s-1"), + "RECO_NT": ("Predicted nighttime ecosystem respiration", "umol CO2 m-2 s-1"), + "FCH4": ("Predicted methane flux", "nmol CH4 m-2 s-1"), + "LE": ("Predicted latent heat flux", "W m-2"), +} def parse_args(): @@ -349,7 +373,11 @@ def write_netcdf_from_csv_shards( ) output_tmp = output_netcdf_path.with_suffix(output_netcdf_path.suffix + ".tmp") - dataset.to_netcdf(output_tmp, engine="h5netcdf") + dataset.to_netcdf( + output_tmp, + engine="h5netcdf", + encoding=era5_netcdf_encoding(dataset, np), + ) output_tmp.replace(output_netcdf_path) @@ -416,38 +444,38 @@ def build_era5_cube_dataset(df, output_columns, num_shards, duplicate_policy, np raise ValueError(f"Unsupported NetCDF duplicate policy: {duplicate_policy}") timestamps = np.sort(df["timestamp"].astype("int64").unique()) - latitudes = np.sort(df["lat"].astype("float64").unique())[::-1] - longitudes = np.sort(df["lon"].astype("float64").unique()) + latitudes = regularize_coordinate_axis( + np.sort(df["lat"].astype("float64").unique())[::-1], + descending=True, + np=np, + ) + longitudes = regularize_coordinate_axis( + np.sort(df["lon"].astype("float64").unique()), + descending=False, + np=np, + ) time_lookup = {value: index for index, value in enumerate(timestamps)} - lat_lookup = {value: index for index, value in enumerate(latitudes)} - lon_lookup = {value: index for index, value in enumerate(longitudes)} + lat_lookup = coordinate_lookup(latitudes) + lon_lookup = coordinate_lookup(longitudes) time_indices = df["timestamp"].map(time_lookup).to_numpy(dtype=np.int64) - lat_indices = df["lat"].map(lat_lookup).to_numpy(dtype=np.int64) - lon_indices = df["lon"].map(lon_lookup).to_numpy(dtype=np.int64) + lat_indices = map_coordinate_indices(df["lat"], lat_lookup, np) + lon_indices = map_coordinate_indices(df["lon"], lon_lookup, np) coords = { - "time": ("time", timestamp_to_time_coordinate(timestamps, np, pd)), + "number": np.asarray(0, dtype=np.int64), + ERA5_TIME_DIM: ( + ERA5_TIME_DIM, + timestamp_to_valid_time_coordinate(timestamps, np, pd), + ), "latitude": ("latitude", latitudes), "longitude": ("longitude", longitudes), + "expver": (ERA5_TIME_DIM, np.full(len(timestamps), "0001", dtype=" float: + return round(float(value), COORDINATE_DECIMALS) + + +def coordinate_lookup(values) -> dict[float, int]: + return {coordinate_key(value): index for index, value in enumerate(values)} + + +def map_coordinate_indices(series, lookup: dict[float, int], np): + indices = [] + missing = [] + for value in series: + index = lookup.get(coordinate_key(value)) + if index is None: + missing.append(float(value)) + continue + indices.append(index) + + if missing: + examples = ", ".join(str(value) for value in missing[:5]) + raise RuntimeError( + "Cannot map coordinate value(s) onto the NetCDF grid. " + f"Examples: {examples}" + ) + return np.asarray(indices, dtype=np.int64) + + +def regularize_coordinate_axis(values, descending: bool, np): + values = np.asarray(values, dtype=np.float64) + if values.size < 3: + return values + + ascending_values = values[::-1] if descending else values + diffs = np.diff(ascending_values) + positive_diffs = diffs[diffs > 0] + if positive_diffs.size == 0: + return values + + rounded_diffs = np.round(positive_diffs, COORDINATE_DECIMALS) + unique_diffs, counts = np.unique(rounded_diffs, return_counts=True) + step = float(unique_diffs[np.argmax(counts)]) + if step <= 0 or not np.isfinite(step): + return values + + span = float(ascending_values[-1] - ascending_values[0]) + expected_count = int(round(span / step)) + 1 + if expected_count <= values.size: + return values + + coverage = values.size / expected_count + if coverage < REGULAR_GRID_MIN_COVERAGE: + return values + + regular_axis = np.round( + ascending_values[0] + np.arange(expected_count, dtype=np.float64) * step, + COORDINATE_DECIMALS, + ) + if descending: + regular_axis = regular_axis[::-1] + return regular_axis + + +def prediction_variable_attrs(column: str, latitudes, longitudes, np) -> dict[str, object]: + target = column.removeprefix("pred_").removeprefix("gt_") + long_name, units = PREDICTION_TARGET_METADATA.get( + target, + (column.replace("_", " "), "unknown"), + ) + if column.startswith("gt_"): + long_name = f"Ground truth {long_name.removeprefix('Predicted ').lower()}" + + lat_increment = coordinate_increment(latitudes, np) + lon_increment = coordinate_increment(longitudes, np) + attrs: dict[str, object] = { + "long_name": long_name, + "units": units, + "standard_name": "unknown", + "coordinates": ERA5_COORDINATES_ATTR, + "GRIB_dataType": "fc", + "GRIB_numberOfPoints": int(len(latitudes) * len(longitudes)), + "GRIB_stepType": "instant", + "GRIB_stepUnits": 1, + "GRIB_gridType": "regular_ll", + "GRIB_typeOfLevel": "surface", + "GRIB_uvRelativeToGrid": 0, + "GRIB_NV": 0, + "GRIB_cfName": "unknown", + "GRIB_cfVarName": column, + "GRIB_shortName": column, + "GRIB_gridDefinitionDescription": "Latitude/Longitude Grid", + "GRIB_iDirectionIncrementInDegrees": lon_increment, + "GRIB_iScansNegatively": 0, + "GRIB_jDirectionIncrementInDegrees": lat_increment, + "GRIB_jPointsAreConsecutive": 0, + "GRIB_jScansPositively": 0, + "GRIB_latitudeOfFirstGridPointInDegrees": float(latitudes[0]) if len(latitudes) else np.nan, + "GRIB_latitudeOfLastGridPointInDegrees": float(latitudes[-1]) if len(latitudes) else np.nan, + "GRIB_longitudeOfFirstGridPointInDegrees": float(longitudes[0]) if len(longitudes) else np.nan, + "GRIB_longitudeOfLastGridPointInDegrees": float(longitudes[-1]) if len(longitudes) else np.nan, + "GRIB_Nx": int(len(longitudes)), + "GRIB_Ny": int(len(latitudes)), + "GRIB_missingValue": float(np.finfo(np.float32).max), + "GRIB_name": long_name, + "GRIB_totalNumber": 0, + "GRIB_units": units, + "GRIB_surface": 0.0, + } + return attrs + + +def coordinate_increment(values, np) -> float: + if len(values) < 2: + return float("nan") + diffs = np.diff(np.asarray(values, dtype=np.float64)) + return float(abs(np.nanmedian(diffs))) + + +def era5_netcdf_encoding(dataset, np) -> dict[str, dict[str, object]]: + encoding: dict[str, dict[str, object]] = { + "number": {"dtype": "int64"}, + "latitude": {"dtype": "float64", "_FillValue": np.nan}, + "longitude": {"dtype": "float64", "_FillValue": np.nan}, + } + if np.issubdtype(dataset[ERA5_TIME_DIM].dtype, np.datetime64): + encoding[ERA5_TIME_DIM] = { + "dtype": "int64", + "units": "seconds since 1970-01-01", + "calendar": "proleptic_gregorian", + } + else: + encoding[ERA5_TIME_DIM] = {"dtype": "int64"} + + if all(dim in dataset.sizes for dim in ERA5_CUBE_DIMS): + cube_chunks = era5_cube_chunks( + tuple(int(dataset.sizes[dim]) for dim in ERA5_CUBE_DIMS) + ) + else: + cube_chunks = None + + for name, variable in dataset.data_vars.items(): + if variable.dtype.kind in {"f", "i", "u"}: + variable_encoding: dict[str, object] = { + "zlib": True, + "complevel": 1, + "shuffle": True, + } + if variable.dtype.kind == "f": + variable_encoding["dtype"] = str(variable.dtype) + variable_encoding["_FillValue"] = ( + np.float32(np.nan) if variable.dtype == np.float32 else np.nan + ) + if variable.dims == ERA5_CUBE_DIMS and cube_chunks is not None: + variable_encoding["chunksizes"] = cube_chunks + elif variable.dims == ERA5_SPATIAL_DIMS: + variable_encoding["chunksizes"] = cube_chunks[1:] if cube_chunks is not None else None + encoding[name] = { + key: value + for key, value in variable_encoding.items() + if value is not None + } + return encoding + + +def era5_cube_chunks(shape: tuple[int, int, int]) -> tuple[int, int, int]: + return tuple( + max(1, min(size, target)) + for size, target in zip(shape, ERA5_CHUNK_TARGETS) + ) + + +def timestamp_to_valid_time_coordinate(timestamps, np, pd): timestamp_text = pd.Series(timestamps).astype("int64").astype(str) lengths = set(timestamp_text.str.len()) if lengths == {14}: @@ -527,20 +762,34 @@ def add_igbp_variable( igbp_is_static = bool((unique_by_cell <= 1).all()) if igbp_is_static: - array = np.full(cube_shape[1:], "", dtype=object) + array = np.full(cube_shape[1:], "", dtype=" Path: diff --git a/scripts/viz_netcdf_structure.py b/scripts/viz_netcdf_structure.py index d7d64f7..6350612 100755 --- a/scripts/viz_netcdf_structure.py +++ b/scripts/viz_netcdf_structure.py @@ -10,8 +10,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -PREFERRED_DIM_ORDER = ("time", "latitude", "longitude", "sample") -ERA5_CUBE_DIMS = ("time", "latitude", "longitude") +ERA5_CUBE_DIMS = ("valid_time", "latitude", "longitude") +PREFERRED_DIM_ORDER = ("valid_time", "latitude", "longitude", "sample") DEFAULT_NETCDF_PATH = ( REPO_ROOT / "experiments/runs" @@ -116,7 +116,13 @@ def dims_text(dims: tuple[str, ...]) -> str: def is_era5_cube(ds: Any) -> bool: - return all(name in ds.sizes for name in ERA5_CUBE_DIMS) + return era5_cube_dims(ds) is not None + + +def era5_cube_dims(ds: Any) -> tuple[str, str, str] | None: + if all(name in ds.sizes for name in ERA5_CUBE_DIMS): + return ERA5_CUBE_DIMS + return None def print_era5_summary(ds: Any) -> None: @@ -125,21 +131,24 @@ def print_era5_summary(ds: Any) -> None: return if not is_era5_cube(ds): - print("era5 layout: not a time/latitude/longitude cube") + print("era5 layout: not a valid_time/latitude/longitude cube") return - total_cells = ds.sizes["time"] * ds.sizes["latitude"] * ds.sizes["longitude"] + cube_dims = era5_cube_dims(ds) + assert cube_dims is not None + time_dim, lat_dim, lon_dim = cube_dims + total_cells = ds.sizes[time_dim] * ds.sizes[lat_dim] * ds.sizes[lon_dim] print("era5 layout: cube") print( " grid: " - f"time={ds.sizes['time']} latitude={ds.sizes['latitude']} " - f"longitude={ds.sizes['longitude']} cells={total_cells}" + f"{time_dim}={ds.sizes[time_dim]} {lat_dim}={ds.sizes[lat_dim]} " + f"{lon_dim}={ds.sizes[lon_dim]} cells={total_cells}" ) prediction_vars = [ name for name, var in ds.data_vars.items() - if name.startswith("pred_") and var.dims == ERA5_CUBE_DIMS + if name.startswith("pred_") and var.dims == cube_dims ] print( " prediction variables: " @@ -153,8 +162,8 @@ def print_era5_summary(ds: Any) -> None: igbp_dims = ds["igbp"].dims if igbp_dims == ("latitude", "longitude"): print(" igbp: static per grid cell, dims=(latitude, longitude)") - elif igbp_dims == ERA5_CUBE_DIMS: - print(" igbp: time-varying, dims=(time, latitude, longitude)") + elif igbp_dims == cube_dims: + print(f" igbp: time-varying, dims=({dims_text(cube_dims)})") else: print(f" igbp: unexpected dims=({dims_text(igbp_dims)})") From 18fa42de111153e6f7871301b080f6b018708450 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Wed, 24 Jun 2026 10:42:49 -0400 Subject: [PATCH 06/14] era5_pipeline created --- .../assign_igbp_from_modis.py | 18 +- era5_pipeline/config_utils.py | 201 +++++ .../download_era5.py | 122 ++- .../download_modis.py | 3 +- era5_pipeline/index_era5.py | 705 ++++++++++++++++++ {era5_download => era5_pipeline}/pipeline.py | 228 ++++-- {era5_download => era5_pipeline}/pipeline.sh | 12 +- .../pipeline_config.yml | 47 +- .../process_modis.py | 20 +- 9 files changed, 1179 insertions(+), 177 deletions(-) rename era5_download/assign_igbp_from_c1.py => era5_pipeline/assign_igbp_from_modis.py (96%) create mode 100644 era5_pipeline/config_utils.py rename {era5_download => era5_pipeline}/download_era5.py (92%) rename {era5_download => era5_pipeline}/download_modis.py (99%) create mode 100644 era5_pipeline/index_era5.py rename {era5_download => era5_pipeline}/pipeline.py (69%) rename {era5_download => era5_pipeline}/pipeline.sh (74%) rename {era5_download => era5_pipeline}/pipeline_config.yml (67%) rename era5_download/transform_modis.py => era5_pipeline/process_modis.py (96%) diff --git a/era5_download/assign_igbp_from_c1.py b/era5_pipeline/assign_igbp_from_modis.py similarity index 96% rename from era5_download/assign_igbp_from_c1.py rename to era5_pipeline/assign_igbp_from_modis.py index c215848..cf8af6f 100755 --- a/era5_download/assign_igbp_from_c1.py +++ b/era5_pipeline/assign_igbp_from_modis.py @@ -24,7 +24,7 @@ from ecoperceiver.constants import IGBP_ACRONYMS_MODIS DEFAULT_DB_PATH = Path("/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db") -DEFAULT_C1_PATH = REPO_ROOT / "experiments" / "data" / "raw_modis" / "201801011200C1.tiff" +DEFAULT_MODIS_PATH = REPO_ROOT / "experiments" / "data" / "raw_modis" / "201801011200C1.tiff" DEFAULT_TABLE = "coord_data" DEFAULT_BATCH_SIZE = 10_000 SQLITE_TIMEOUT_SECONDS = 60.0 @@ -65,10 +65,10 @@ def parse_args() -> argparse.Namespace: help=f"SQLite database to modify. Default: {DEFAULT_DB_PATH}", ) parser.add_argument( - "--c1-path", + "--modis-path", type=Path, - default=DEFAULT_C1_PATH, - help=f"MODIS MCD12C1 GeoTIFF. Default: {DEFAULT_C1_PATH}", + default=DEFAULT_MODIS_PATH, + help=f"MODIS MCD12C1 GeoTIFF. Default: {DEFAULT_MODIS_PATH}", ) parser.add_argument( "--table", @@ -308,7 +308,7 @@ def print_counts(title: str, counts: Counter[str]) -> None: def print_summary( *, db_path: Path, - c1_path: Path, + modis_path: Path, table: str, only_null: bool, row_count: int, @@ -319,7 +319,7 @@ def print_summary( skipped_missing_coords: int, ) -> None: print(f"DB: {db_path}") - print(f"C1: {c1_path}") + print(f"MODIS: {modis_path}") print(f"Table: {table}") print("Sampling: nearest coord_data lat/lon point") print(f"Rows scanned: {row_count:,}") @@ -375,12 +375,12 @@ def apply_assignments( def main() -> int: args = parse_args() db_path = resolve_path(args.db_path) - c1_path = resolve_path(args.c1_path) + modis_path = resolve_path(args.modis_path) if not db_path.exists(): raise SystemExit(f"Database does not exist: {db_path}") - grid = load_c1_raster(c1_path) + grid = load_c1_raster(modis_path) with connect_database(db_path, readonly=not args.write) as conn: ensure_coord_table(conn, args.table) assignments, old_counts, new_counts, transitions, skipped_missing_coords, row_count = plan_assignments( @@ -392,7 +392,7 @@ def main() -> int: ) print_summary( db_path=db_path, - c1_path=c1_path, + modis_path=modis_path, table=args.table, only_null=args.only_null, row_count=row_count, diff --git a/era5_pipeline/config_utils.py b/era5_pipeline/config_utils.py new file mode 100644 index 0000000..2b89b15 --- /dev/null +++ b/era5_pipeline/config_utils.py @@ -0,0 +1,201 @@ +"""Shared date-range and path inference for the ERA5/MODIS pipeline.""" + +from __future__ import annotations + +import calendar +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class PipelineDateRange: + start: date + end: date + label: str + + @property + def start_datetime(self) -> datetime: + return datetime(self.start.year, self.start.month, self.start.day, 0, 0, 0) + + @property + def end_datetime(self) -> datetime: + return datetime(self.end.year, self.end.month, self.end.day, 23, 0, 0) + + @property + def local_window_start(self) -> str: + return f"{self.start.isoformat()} 00:00:00" + + @property + def local_window_end(self) -> str: + return f"{self.end.isoformat()} 23:59:59" + + +@dataclass(frozen=True) +class PipelinePaths: + data_root: Path + output_dir: Path + db_path: Path + netcdf_dir: Path + zip_dir: Path + raw_modis_dir: Path + sqlite_temp_dir: Path + modis_landcover_path: Path + + +def config_section(config: dict[str, Any], name: str) -> dict[str, Any]: + value = config.get(name, {}) or {} + if not isinstance(value, dict): + raise SystemExit(f"Config section {name!r} must be a YAML mapping.") + return value + + +def resolve_repo_path( + value: str | Path | None, + repo_root: Path, + *, + default: Path | None = None, +) -> Path | None: + if value is None: + return default + + path = Path(value).expanduser() + if path.is_absolute(): + return path + return repo_root / path + + +def resolve_raw_modis_file( + value: str | Path | None, + repo_root: Path, + raw_modis_dir: Path, + *, + default_name: str, +) -> Path: + if value is None: + return raw_modis_dir / default_name + + path = Path(value).expanduser() + if path.is_absolute(): + return path + if path.parent == Path("."): + return raw_modis_dir / path + return repo_root / path + + +def parse_config_date(value: Any, *, field_name: str) -> date: + if value is None: + raise SystemExit(f"Config field {field_name!r} is required.") + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + try: + return date.fromisoformat(str(value)) + except ValueError as exc: + raise SystemExit( + f"Config field {field_name!r} must be an ISO date like YYYY-MM-DD." + ) from exc + + +def date_range_label(start: date, end: date) -> str: + full_year_start = start.month == 1 and start.day == 1 + full_year_end = ( + end.month == 12 + and end.day == calendar.monthrange(end.year, 12)[1] + ) + if full_year_start and full_year_end: + if start.year == end.year: + return str(start.year) + return f"{start.year}_{end.year}" + return f"{start:%Y%m%d}_{end:%Y%m%d}" + + +def configured_date_range(config: dict[str, Any]) -> PipelineDateRange: + start = parse_config_date(config.get("start_date"), field_name="start_date") + end = parse_config_date(config.get("end_date"), field_name="end_date") + if end < start: + raise SystemExit( + f"Config field 'end_date' must be on or after 'start_date': {start} > {end}." + ) + return PipelineDateRange(start=start, end=end, label=date_range_label(start, end)) + + +def inferred_pipeline_paths(config: dict[str, Any], repo_root: Path) -> PipelinePaths: + date_range = configured_date_range(config) + path_config = config_section(config, "paths") + index_config = config_section(config, "index_era5") + download_modis_config = config_section(config, "download_modis") + assign_igbp_config = config_section(config, "assign_igbp_from_modis") + process_modis_config = config_section(config, "process_modis") + + data_root = resolve_repo_path( + path_config.get("data_root", "experiments/data"), + repo_root, + ) + assert data_root is not None + + output_dir = resolve_repo_path( + path_config.get("output_dir"), + repo_root, + default=data_root / date_range.label, + ) + assert output_dir is not None + + db_path = resolve_repo_path( + path_config.get("db_path"), + repo_root, + default=output_dir / f"era5_{date_range.label}.db", + ) + assert db_path is not None + + netcdf_dir = resolve_repo_path( + path_config.get("netcdf_dir"), + repo_root, + default=output_dir / "era5_data", + ) + assert netcdf_dir is not None + + zip_dir = resolve_repo_path( + path_config.get("zip_dir"), + repo_root, + default=output_dir / "era5_zip", + ) + assert zip_dir is not None + + raw_modis_dir = resolve_repo_path( + ( + path_config.get("raw_modis_dir") + or download_modis_config.get("output_dir") + or process_modis_config.get("input_dir") + ), + repo_root, + default=output_dir / "raw_modis", + ) + assert raw_modis_dir is not None + + sqlite_temp_dir = resolve_repo_path( + index_config.get("temp_dir") or path_config.get("sqlite_temp_dir"), + repo_root, + default=output_dir / "sqlite-tmp", + ) + assert sqlite_temp_dir is not None + + modis_landcover_path = resolve_raw_modis_file( + assign_igbp_config.get("modis_path"), + repo_root, + raw_modis_dir, + default_name=f"{date_range.end.year}01011200C1.tiff", + ) + + return PipelinePaths( + data_root=data_root, + output_dir=output_dir, + db_path=db_path, + netcdf_dir=netcdf_dir, + zip_dir=zip_dir, + raw_modis_dir=raw_modis_dir, + sqlite_temp_dir=sqlite_temp_dir, + modis_landcover_path=modis_landcover_path, + ) diff --git a/era5_download/download_era5.py b/era5_pipeline/download_era5.py similarity index 92% rename from era5_download/download_era5.py rename to era5_pipeline/download_era5.py index 515f33b..271665d 100644 --- a/era5_download/download_era5.py +++ b/era5_pipeline/download_era5.py @@ -14,7 +14,6 @@ import argparse import calendar -from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -35,11 +34,12 @@ SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent DEFAULT_CONFIG_PATH = SCRIPT_DIR / "pipeline_config.yml" -DEFAULT_DB_FILENAME = "era5_2016_2017.db" if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from config_utils import configured_date_range, inferred_pipeline_paths + MISSING_DEPENDENCIES: dict[str, str] = {} try: @@ -389,7 +389,7 @@ def parse_args() -> argparse.Namespace: "--max-workers", type=int, default=None, - help="Override download.max_workers for concurrent CDS downloads.", + help="Override download_era5.max_workers for concurrent CDS downloads.", ) args = parser.parse_args() @@ -442,23 +442,9 @@ def resolve_path(value: str | os.PathLike | None, *, default: Path | None = None return (REPO_ROOT / path).resolve() -def years_to_range(years: Iterable[int | str]) -> tuple[datetime, datetime]: - year_values = sorted(int(year) for year in years) - if not year_values: - raise SystemExit("Config field 'years' must contain at least one year.") - start = datetime(year_values[0], 1, 1, 0, 0, 0) - end = datetime(year_values[-1], 12, 31, 23, 0, 0) - return start, end - - def configured_time_range(config: dict[str, Any]) -> tuple[datetime, datetime]: - download_config = section(config, "download") - if download_config.get("start") and download_config.get("end"): - return ( - datetime.fromisoformat(str(download_config["start"])), - datetime.fromisoformat(str(download_config["end"])), - ) - return years_to_range(config.get("years", [])) + date_range = configured_date_range(config) + return date_range.start_datetime, date_range.end_datetime def full_hours() -> list[str]: @@ -512,10 +498,10 @@ def build_request_groups(config: dict[str, Any]) -> list[RequestGroup]: if end < start: raise SystemExit(f"Download end date is before start date: {start} > {end}") - download_config = section(config, "download") + download_config = section(config, "download_era5") bbox = download_config.get("bbox", [90, -180, -90, 180]) if not isinstance(bbox, list) or len(bbox) != 4: - raise SystemExit("download.bbox must be [north, west, south, east].") + raise SystemExit("download_era5.bbox must be [north, west, south, east].") areas = split_bbox_latitude_bands( bbox, download_config.get("latitude_band_degrees"), @@ -553,7 +539,7 @@ def build_request_groups(config: dict[str, Any]) -> list[RequestGroup]: continue if chunk not in {"daily", "monthly"}: - raise SystemExit("download.temporal_chunk must be 'daily' or 'monthly'.") + raise SystemExit("download_era5.temporal_chunk must be 'daily' or 'monthly'.") for day in iter_day_starts(active_start, active_end): day_start = datetime(day.year, day.month, day.day, 0, 0, 0) @@ -575,7 +561,7 @@ def build_request_groups(config: dict[str, Any]) -> list[RequestGroup]: def cds_request_payload(config: dict[str, Any], group: RequestGroup) -> dict[str, Any]: - download_config = section(config, "download") + download_config = section(config, "download_era5") product_type = download_config.get("product_type", "reanalysis") variables = download_config.get("variables") or ERA5_VARIABLES return { @@ -598,28 +584,24 @@ def extract_zip(zip_path: Path, output_dir: Path) -> None: def configured_download_workers(config: dict[str, Any], args: argparse.Namespace) -> int: - download_config = section(config, "download") + download_config = section(config, "download_era5") value = args.max_workers if value is None: value = download_config.get("max_workers", 1) try: workers = int(value) except (TypeError, ValueError) as exc: - raise SystemExit("download.max_workers must be an integer >= 1.") from exc + raise SystemExit("download_era5.max_workers must be an integer >= 1.") from exc if workers < 1: - raise SystemExit("download.max_workers must be an integer >= 1.") + raise SystemExit("download_era5.max_workers must be an integer >= 1.") return workers def download_groups(config: dict[str, Any], args: argparse.Namespace) -> None: - paths = section(config, "paths") - download_config = section(config, "download") - zip_dir = resolve_path(paths.get("zip_dir"), default=REPO_ROOT / "experiments/data/era5_zip") - netcdf_dir = resolve_path( - paths.get("netcdf_dir"), - default=REPO_ROOT / "experiments/data/era5_data", - ) - assert zip_dir is not None and netcdf_dir is not None + inferred_paths = inferred_pipeline_paths(config, REPO_ROOT) + download_config = section(config, "download_era5") + zip_dir = inferred_paths.zip_dir + netcdf_dir = inferred_paths.netcdf_dir groups = build_request_groups(config) if args.limit_groups is not None: @@ -784,10 +766,13 @@ def init_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: ); """ ) + date_range = configured_date_range(config) metadata = { - "generator": "era5_download/download_era5.py", + "generator": "era5_pipeline/download_era5.py", "config_metadata": section(config, "metadata"), - "years": config.get("years"), + "start_date": date_range.start.isoformat(), + "end_date": date_range.end.isoformat(), + "date_range_label": date_range.label, "created_utc": datetime.now(timezone.utc).isoformat(), } conn.executemany( @@ -814,7 +799,7 @@ def rounded_coord_key(lat: float, lon: float) -> tuple[float, float]: class TimezoneResolver: def __init__(self, config: dict[str, Any]): - timezone_config = section(section(config, "processing"), "timezone") + timezone_config = section(section(config, "process_era5"), "timezone") self.enabled = bool(timezone_config.get("enabled", True)) self.timestamp_policy = str(timezone_config.get("timestamp_policy", "local")).lower() self.method = str(timezone_config.get("method", "timezonefinder")).lower() @@ -937,8 +922,8 @@ def datetimes_to_int(values: "pd.Series") -> "pd.Series": class LandSeaMask: def __init__(self, config: dict[str, Any]): - processing_config = section(config, "processing") - mask_config = section(processing_config, "land_sea_mask") + process_config = section(config, "process_era5") + mask_config = section(process_config, "land_sea_mask") path_config = section(config, "paths") self.enabled = bool(mask_config.get("enabled", False)) self.threshold = float(mask_config.get("threshold", 0.5)) @@ -956,7 +941,7 @@ def __init__(self, config: dict[str, Any]): raise SystemExit(f"Land-sea mask file does not exist: {path}") ds = xr.open_dataset( path, - engine=mask_config.get("xarray_engine") or processing_config.get("xarray_engine"), + engine=mask_config.get("xarray_engine") or process_config.get("xarray_engine"), ) variable = mask_config.get("variable") if variable is None: @@ -1098,25 +1083,26 @@ class Era5TimeBlock: class Era5PostProcessor: def __init__(self, config: dict[str, Any], conn: sqlite3.Connection): - processing_config = section(config, "processing") + process_config = section(config, "process_era5") self.config = config self.conn = conn - self.batch_size = int(processing_config.get("batch_size", 50_000)) - time_block_size = processing_config.get("time_block_size") + self.batch_size = int(process_config.get("batch_size", 50_000)) + time_block_size = process_config.get("time_block_size") self.time_block_size = int(time_block_size) if time_block_size else None - self.xarray_engine = processing_config.get("xarray_engine") + self.xarray_engine = process_config.get("xarray_engine") self.timezone_resolver = TimezoneResolver(config) self.land_mask = LandSeaMask(config) self.writer = Era5DatabaseWriter(conn, self.batch_size) self.local_window = self.parse_local_window() def parse_local_window(self) -> tuple["pd.Timestamp | None", "pd.Timestamp | None"]: - timezone_config = section(section(self.config, "processing"), "timezone") + timezone_config = section(section(self.config, "process_era5"), "timezone") window_config = section(timezone_config, "local_window") if not window_config.get("enabled", False): return None, None - start = pd.Timestamp(window_config["start"]) if window_config.get("start") else None - end = pd.Timestamp(window_config["end"]) if window_config.get("end") else None + date_range = configured_date_range(self.config) + start = pd.Timestamp(window_config.get("start") or date_range.local_window_start) + end = pd.Timestamp(window_config.get("end") or date_range.local_window_end) return start, end def process_groups(self, netcdf_dir: Path, limit_groups: int | None = None) -> int: @@ -1394,12 +1380,16 @@ def minmax_normalization(df: "pd.DataFrame") -> "pd.DataFrame": def configure_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: - processing_config = section(config, "processing") + process_config = section(config, "process_era5") + inferred_paths = inferred_pipeline_paths(config, REPO_ROOT) conn.execute("PRAGMA journal_mode = DELETE") conn.execute("PRAGMA synchronous = NORMAL") conn.execute("PRAGMA temp_store = FILE") - conn.execute(f"PRAGMA threads = {int(processing_config.get('sqlite_threads', 8))}") - temp_dir = resolve_path(processing_config.get("sqlite_temp_dir")) + conn.execute(f"PRAGMA threads = {int(process_config.get('sqlite_threads', 8))}") + temp_dir = resolve_path( + process_config.get("sqlite_temp_dir"), + default=inferred_paths.sqlite_temp_dir, + ) if temp_dir is not None: temp_dir.mkdir(parents=True, exist_ok=True) try: @@ -1407,29 +1397,14 @@ def configure_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: except sqlite3.OperationalError as exc: print(f"Could not set sqlite temp_store_directory: {exc}", file=sys.stderr) - -def create_indexes(conn: sqlite3.Connection) -> None: - conn.execute( - """ - CREATE INDEX IF NOT EXISTS idx_ec_data_coord_id_timestamp_id - ON ec_data(coord_id, timestamp, id) - """ - ) - conn.execute("ANALYZE") - - def process_to_database(config: dict[str, Any], args: argparse.Namespace) -> None: - paths = section(config, "paths") - output_dir = resolve_path( - paths.get("output_dir"), - default=REPO_ROOT / "experiments/data/raw_era5", - ) - db_path = resolve_path(paths.get("db_path"), default=output_dir / DEFAULT_DB_FILENAME) - netcdf_dir = resolve_path(paths.get("netcdf_dir"), default=output_dir / "era5_data") - assert output_dir is not None and db_path is not None and netcdf_dir is not None + inferred_paths = inferred_pipeline_paths(config, REPO_ROOT) + output_dir = inferred_paths.output_dir + db_path = inferred_paths.db_path + netcdf_dir = inferred_paths.netcdf_dir - processing_config = section(config, "processing") - recreate_db = bool(processing_config.get("recreate_db", True) or args.overwrite_db) + process_config = section(config, "process_era5") + recreate_db = bool(process_config.get("recreate_db", True) or args.overwrite_db) if args.dry_run: groups = list(iter_netcdf_group_dirs(netcdf_dir)) if netcdf_dir.exists() else [] print(f"Would process {len(groups)} NetCDF group(s) into {db_path}") @@ -1445,9 +1420,6 @@ def process_to_database(config: dict[str, Any], args: argparse.Namespace) -> Non init_sqlite(conn, config) processor = Era5PostProcessor(config, conn) total_rows = processor.process_groups(netcdf_dir, args.limit_groups) - if processing_config.get("create_index", True): - print("Creating SQLite indexes...") - create_indexes(conn) conn.commit() print(f"SQLite export complete: {db_path} ({total_rows:,} ec_data rows inserted)") @@ -1456,7 +1428,7 @@ def main() -> int: args = parse_args() config = load_config(args.config) - download_enabled = bool(section(config, "download").get("enabled", True)) + download_enabled = bool(section(config, "download_era5").get("enabled", True)) if not args.process_only and download_enabled: download_groups(config, args) elif args.download_only: diff --git a/era5_download/download_modis.py b/era5_pipeline/download_modis.py similarity index 99% rename from era5_download/download_modis.py rename to era5_pipeline/download_modis.py index c80081f..4d23c6a 100755 --- a/era5_download/download_modis.py +++ b/era5_pipeline/download_modis.py @@ -132,7 +132,8 @@ def parse_args() -> argparse.Namespace: description=( "Download global MODIS rasters for an inclusive date range. " "Annual C1 land-cover files are downloaded once per year touched " - "by the requested range. Defaults to 2016-01-01 through 2017-12-31." + f"by the requested range. Defaults to {DEFAULT_START_DATE.isoformat()} " + f"through {DEFAULT_END_DATE.isoformat()}." ) ) parser.add_argument( diff --git a/era5_pipeline/index_era5.py b/era5_pipeline/index_era5.py new file mode 100644 index 0000000..c9b7170 --- /dev/null +++ b/era5_pipeline/index_era5.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +"""Create persistent SQLite indexes for evaluation and MODIS lookup.""" + +from __future__ import annotations + +import argparse +from contextlib import closing, contextmanager +import os +from pathlib import Path +import shutil +import sqlite3 +import sys +import time +from urllib.parse import quote + +try: + from tqdm.auto import tqdm +except ModuleNotFoundError: + tqdm = None + + +DEFAULT_DB_PATH = Path("experiments/data/era5.db") +DEFAULT_TABLE = "ec_data" +DEFAULT_INDEX_NAME = "idx_ec_data_coord_id_timestamp_id" +DEFAULT_INDEX_COLUMNS = ("coord_id", "timestamp", "id") +SQLITE_PROGRESS_OPCODES = 100_000 +HEARTBEAT_SECONDS = 15.0 +PROGRESS_STATUS_SECONDS = 2.0 +SQLITE_TIMEOUT_SECONDS = 60.0 +ESTIMATED_INDEX_BYTES_PER_ROW = 32.0 +ESTIMATED_TEMP_SORT_MULTIPLIER = 1.25 +MIN_FREE_SPACE_MARGIN_BYTES = 10 * 1024**3 +DEFAULT_BUILD_JOURNAL_MODE = "DELETE" +DEFAULT_PROGRESS_MODE = "auto" +DEFAULT_SQLITE_THREADS = 8 + + +def quote_identifier(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def format_bytes(num_bytes: float) -> str: + units = ("B", "KiB", "MiB", "GiB", "TiB") + value = float(num_bytes) + for unit in units: + if abs(value) < 1024.0 or unit == units[-1]: + return f"{value:.1f} {unit}" if unit != "B" else f"{value:.0f} {unit}" + value /= 1024.0 + return f"{value:.1f} TiB" + + +def safe_file_size(path: Path) -> int: + try: + return path.stat().st_size + except OSError: + return 0 + + +def process_temp_file_bytes(temp_dir: Path) -> int: + """Return bytes held by SQLite temp files, including unlinked Linux files.""" + fd_dir = Path("/proc") / str(os.getpid()) / "fd" + temp_prefix = f"{temp_dir}{os.sep}" + total = 0 + seen: set[tuple[int, int]] = set() + + if fd_dir.exists(): + try: + fds = list(fd_dir.iterdir()) + except OSError: + fds = [] + for fd in fds: + try: + target = os.readlink(fd) + except OSError: + continue + if not target.startswith(temp_prefix): + continue + try: + stat_result = fd.stat() + except OSError: + continue + key = (stat_result.st_dev, stat_result.st_ino) + if key in seen: + continue + seen.add(key) + total += stat_result.st_size + + try: + paths = list(temp_dir.iterdir()) + except OSError: + paths = [] + for path in paths: + try: + stat_result = path.stat() + except OSError: + continue + key = (stat_result.st_dev, stat_result.st_ino) + if key in seen: + continue + seen.add(key) + if path.is_file(): + total += stat_result.st_size + + return total + + +def progress_status_text( + *, + opcodes: int, + started_at: float, + db_path: Path | None, + temp_dir: Path | None, +) -> str: + elapsed = max(time.monotonic() - started_at, 1e-9) + parts = [ + f"opcodes={opcodes:,}", + f"elapsed={elapsed / 60.0:.1f} min", + f"rate={opcodes / elapsed:,.0f} opcode/s", + ] + + if db_path is not None: + db_bytes = safe_file_size(db_path) + wal_bytes = safe_file_size(db_path.with_name(f"{db_path.name}-wal")) + journal_bytes = safe_file_size(db_path.with_name(f"{db_path.name}-journal")) + parts.append(f"db={format_bytes(db_bytes)}") + if wal_bytes: + parts.append(f"wal={format_bytes(wal_bytes)}") + if journal_bytes: + parts.append(f"journal={format_bytes(journal_bytes)}") + + if temp_dir is not None: + temp_bytes = process_temp_file_bytes(temp_dir) + parts.append(f"temp={format_bytes(temp_bytes)}") + try: + free_bytes = shutil.disk_usage(existing_path_for_disk_check(temp_dir)).free + parts.append(f"free={format_bytes(free_bytes)}") + except RuntimeError: + pass + + return ", ".join(parts) + + +class SqliteProgress: + def __init__( + self, + desc: str, + *, + db_path: Path | None, + temp_dir: Path | None, + progress_mode: str, + ): + self.desc = desc + self.db_path = db_path + self.temp_dir = temp_dir + self.progress_mode = self.resolve_progress_mode(progress_mode) + self.opcodes = 0 + self.started_at = time.monotonic() + self.last_heartbeat = self.started_at + self.last_status = self.started_at + self.progress = ( + tqdm( + total=None, + desc=desc, + unit="opcode", + unit_scale=True, + dynamic_ncols=True, + mininterval=1.0, + ) + if self.progress_mode == "tqdm" + else None + ) + + @staticmethod + def resolve_progress_mode(progress_mode: str) -> str: + if progress_mode == "none": + return "none" + if progress_mode == "heartbeat": + return "heartbeat" + if progress_mode == "tqdm": + if tqdm is None: + print("tqdm is not installed; falling back to heartbeat progress.", flush=True) + return "heartbeat" + return "tqdm" + if tqdm is not None and sys.stderr.isatty(): + return "tqdm" + return "heartbeat" + + def status_text(self) -> str: + try: + return progress_status_text( + opcodes=self.opcodes, + started_at=self.started_at, + db_path=self.db_path, + temp_dir=self.temp_dir, + ) + except OSError as exc: + return f"opcodes={self.opcodes:,}, status unavailable: {exc}" + + def update(self) -> int: + self.opcodes += SQLITE_PROGRESS_OPCODES + if self.progress_mode == "none": + return 0 + + now = time.monotonic() + if self.progress is not None: + self.progress.update(SQLITE_PROGRESS_OPCODES) + if now - self.last_status >= PROGRESS_STATUS_SECONDS: + self.progress.set_postfix_str(self.status_text(), refresh=True) + self.last_status = now + return 0 + + if now - self.last_heartbeat >= HEARTBEAT_SECONDS: + print(f"{self.desc}: {self.status_text()}", flush=True) + self.last_heartbeat = now + return 0 + + def close(self) -> None: + if self.progress is not None: + self.progress.set_postfix_str(self.status_text(), refresh=True) + self.progress.close() + + +@contextmanager +def sqlite_progress( + conn: sqlite3.Connection, + desc: str, + *, + db_path: Path | None, + temp_dir: Path | None, + progress_mode: str, +): + progress = SqliteProgress( + desc, + db_path=db_path, + temp_dir=temp_dir, + progress_mode=progress_mode, + ) + conn.set_progress_handler(progress.update, SQLITE_PROGRESS_OPCODES) + try: + yield + finally: + conn.set_progress_handler(None, 0) + progress.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Create the composite ec_data index used by eval startup and " + "active MODIS coordinate lookup." + ) + ) + parser.add_argument( + "--db-path", + type=Path, + default=DEFAULT_DB_PATH, + help=f"SQLite database to index. Default: {DEFAULT_DB_PATH}", + ) + parser.add_argument( + "--table", + default=DEFAULT_TABLE, + help=f"Table to index. Default: {DEFAULT_TABLE}", + ) + parser.add_argument( + "--index-name", + default=DEFAULT_INDEX_NAME, + help=f"Composite index name. Default: {DEFAULT_INDEX_NAME}", + ) + parser.add_argument( + "--columns", + nargs="+", + default=list(DEFAULT_INDEX_COLUMNS), + help=( + "Columns for the composite index. " + f"Default: {' '.join(DEFAULT_INDEX_COLUMNS)}" + ), + ) + parser.add_argument( + "--analyze", + action=argparse.BooleanOptionalAction, + default=True, + help="Run ANALYZE after index changes so SQLite picks the new index.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned index changes without modifying the database.", + ) + parser.add_argument( + "--temp-dir", + type=Path, + default=None, + help=( + "Directory for SQLite temporary sorter files. Default: a sqlite-tmp " + "directory next to the database." + ), + ) + parser.add_argument( + "--journal-mode", + choices=("DELETE", "TRUNCATE", "PERSIST", "WAL"), + default=DEFAULT_BUILD_JOURNAL_MODE, + help=( + "Journal mode to use while building the index. DELETE avoids writing " + "the full index through a large WAL file. Default: DELETE." + ), + ) + parser.add_argument( + "--skip-preflight", + action="store_true", + help="Skip disk/page-count estimates before creating a missing index.", + ) + parser.add_argument( + "--estimate-index-bytes-per-row", + type=float, + default=ESTIMATED_INDEX_BYTES_PER_ROW, + help=( + "Preflight estimate for final index bytes per table row. " + f"Default: {ESTIMATED_INDEX_BYTES_PER_ROW:g}." + ), + ) + parser.add_argument( + "--progress", + choices=("auto", "tqdm", "heartbeat", "none"), + default=DEFAULT_PROGRESS_MODE, + help=( + "Progress display. auto uses tqdm on an interactive terminal and " + "heartbeat log lines otherwise. tqdm is indeterminate because " + "SQLite does not expose total CREATE INDEX work. Default: auto." + ), + ) + parser.add_argument( + "--threads", + type=int, + default=DEFAULT_SQLITE_THREADS, + help=( + "SQLite worker threads for sort operations such as CREATE INDEX. " + f"Default: {DEFAULT_SQLITE_THREADS}." + ), + ) + return parser.parse_args() + + +def ensure_valid_args(args: argparse.Namespace) -> None: + args.db_path = args.db_path.expanduser().resolve() + if not args.db_path.exists(): + raise SystemExit(f"Database does not exist: {args.db_path}") + if not args.columns: + raise SystemExit("--columns must contain at least one column.") + if args.temp_dir is None: + args.temp_dir = args.db_path.parent / "sqlite-tmp" + else: + args.temp_dir = args.temp_dir.expanduser().resolve() + if args.estimate_index_bytes_per_row <= 0: + raise SystemExit("--estimate-index-bytes-per-row must be positive.") + if args.threads < 0: + raise SystemExit("--threads must be zero or greater.") + + +def connect_database(db_path: Path, *, readonly: bool) -> sqlite3.Connection: + if readonly: + db_uri = f"file:{quote(str(db_path), safe='/')}?mode=ro" + return sqlite3.connect(db_uri, timeout=SQLITE_TIMEOUT_SECONDS, uri=True) + return sqlite3.connect(db_path, timeout=SQLITE_TIMEOUT_SECONDS) + + +def table_columns(conn: sqlite3.Connection, table: str) -> set[str]: + rows = conn.execute(f"PRAGMA table_info({quote_identifier(table)})").fetchall() + if not rows: + raise RuntimeError(f"Table does not exist or has no columns: {table}") + return {row[1] for row in rows} + + +def ensure_table_has_columns( + conn: sqlite3.Connection, + table: str, + columns: list[str], +) -> None: + available_columns = table_columns(conn, table) + missing_columns = [column for column in columns if column not in available_columns] + if missing_columns: + raise RuntimeError( + f"Cannot index {table}; missing column(s): {', '.join(missing_columns)}" + ) + + +def index_columns(conn: sqlite3.Connection, index_name: str) -> tuple[str, ...] | None: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?", + (index_name,), + ).fetchall() + if not rows: + return None + + return tuple( + column_row[2] + for column_row in conn.execute( + f"PRAGMA index_info({quote_identifier(index_name)})" + ) + ) + + +def table_row_estimate(conn: sqlite3.Connection, table: str) -> int: + sequence_exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'" + ).fetchone() + if sequence_exists is not None: + row = conn.execute( + "SELECT seq FROM sqlite_sequence WHERE name = ?", + (table,), + ).fetchone() + if row is not None and row[0] is not None: + return int(row[0]) + + try: + row = conn.execute(f"SELECT MAX(rowid) FROM {quote_identifier(table)}").fetchone() + except sqlite3.Error as exc: + raise RuntimeError( + f"Cannot cheaply estimate row count for {table!r}; " + "rerun with --skip-preflight if you want to proceed." + ) from exc + return int(row[0] or 0) + + +def sqlite_pragma_int(conn: sqlite3.Connection, pragma_name: str) -> int: + row = conn.execute(f"PRAGMA {pragma_name}").fetchone() + if row is None or row[0] is None: + raise RuntimeError(f"Could not read PRAGMA {pragma_name}.") + return int(row[0]) + + +def existing_path_for_disk_check(path: Path) -> Path: + current = path + while not current.exists(): + parent = current.parent + if parent == current: + raise RuntimeError(f"No existing parent directory found for {path}") + current = parent + return current + + +def same_filesystem(path_a: Path, path_b: Path) -> bool: + return os.stat(path_a).st_dev == os.stat(path_b).st_dev + + +def ensure_enough_space(path: Path, required_bytes: float, label: str) -> None: + free_bytes = shutil.disk_usage(path).free + print( + f"{label} free space: {format_bytes(free_bytes)} " + f"(estimated need: {format_bytes(required_bytes)})", + flush=True, + ) + if free_bytes < required_bytes: + raise RuntimeError( + f"Not enough free space on {path} for {label}: " + f"need about {format_bytes(required_bytes)}, " + f"available {format_bytes(free_bytes)}." + ) + + +def preflight_index_build( + conn: sqlite3.Connection, + *, + table: str, + db_path: Path, + temp_dir: Path, + estimate_index_bytes_per_row: float, +) -> None: + page_size = sqlite_pragma_int(conn, "page_size") + page_count = sqlite_pragma_int(conn, "page_count") + max_page_count = sqlite_pragma_int(conn, "max_page_count") + row_count = table_row_estimate(conn, table) + estimated_index_bytes = row_count * estimate_index_bytes_per_row + estimated_index_pages = int((estimated_index_bytes + page_size - 1) // page_size) + estimated_final_pages = page_count + estimated_index_pages + estimated_temp_bytes = estimated_index_bytes * ESTIMATED_TEMP_SORT_MULTIPLIER + + print("Preflight estimate:", flush=True) + print(f" table rows: {row_count:,}", flush=True) + print(f" current DB size: {format_bytes(page_count * page_size)}", flush=True) + print(f" estimated final index size: {format_bytes(estimated_index_bytes)}", flush=True) + print(f" estimated SQLite temp sort space: {format_bytes(estimated_temp_bytes)}", flush=True) + print( + f" estimated final page count: {estimated_final_pages:,} " + f"of {max_page_count:,}", + flush=True, + ) + + if estimated_final_pages >= max_page_count: + raise RuntimeError( + "The estimated final database page count exceeds SQLite's configured " + f"max_page_count ({max_page_count:,})." + ) + + db_parent = db_path.parent + temp_space_path = existing_path_for_disk_check(temp_dir) + if same_filesystem(db_parent, temp_space_path): + required_bytes = ( + estimated_index_bytes + + estimated_temp_bytes + + MIN_FREE_SPACE_MARGIN_BYTES + ) + ensure_enough_space(db_parent, required_bytes, "DB/temp filesystem") + else: + ensure_enough_space( + db_parent, + estimated_index_bytes + MIN_FREE_SPACE_MARGIN_BYTES, + "DB filesystem", + ) + ensure_enough_space( + temp_space_path, + estimated_temp_bytes + MIN_FREE_SPACE_MARGIN_BYTES, + "SQLite temp filesystem", + ) + + +def prepare_temp_dir(temp_dir: Path, *, dry_run: bool) -> None: + if dry_run: + print(f"SQLite temp dir: {temp_dir}") + return + + temp_dir.mkdir(parents=True, exist_ok=True) + if not temp_dir.is_dir(): + raise RuntimeError(f"SQLite temp path is not a directory: {temp_dir}") + + os.environ["SQLITE_TMPDIR"] = str(temp_dir) + os.environ["TMPDIR"] = str(temp_dir) + print(f"SQLite temp dir: {temp_dir}", flush=True) + + +def configure_sqlite_runtime(conn: sqlite3.Connection, *, threads: int) -> None: + conn.execute("PRAGMA temp_store = FILE") + row = conn.execute(f"PRAGMA threads = {threads}").fetchone() + actual_threads = int(row[0]) if row is not None else threads + print(f"SQLite worker threads: {actual_threads}", flush=True) + if actual_threads != threads: + print( + f"Requested {threads} SQLite worker threads, " + f"but SQLite accepted {actual_threads}.", + flush=True, + ) + + +@contextmanager +def sqlite_index_build_mode( + conn: sqlite3.Connection, + *, + journal_mode: str, + dry_run: bool, +): + original_row = conn.execute("PRAGMA journal_mode").fetchone() + original_mode = str(original_row[0]).upper() if original_row else "UNKNOWN" + requested_mode = journal_mode.upper() + active_mode = original_mode + print(f"Original journal_mode: {original_mode}", flush=True) + + if not dry_run and requested_mode != original_mode: + row = conn.execute(f"PRAGMA journal_mode = {requested_mode}").fetchone() + active_mode = str(row[0]).upper() if row else requested_mode + if active_mode != requested_mode: + raise RuntimeError( + f"Could not switch SQLite journal_mode to {requested_mode}; " + f"SQLite reported {active_mode}." + ) + print(f"Build journal_mode: {active_mode}", flush=True) + + try: + yield + except BaseException: + if not dry_run: + conn.rollback() + raise + finally: + if not dry_run and original_mode != "UNKNOWN" and active_mode != original_mode: + row = conn.execute(f"PRAGMA journal_mode = {original_mode}").fetchone() + restored_mode = str(row[0]).upper() if row else "UNKNOWN" + print(f"Restored journal_mode: {restored_mode}", flush=True) + + +def create_composite_index( + conn: sqlite3.Connection, + *, + table: str, + index_name: str, + columns: list[str], + db_path: Path, + temp_dir: Path, + progress_mode: str, + dry_run: bool, +) -> None: + existing_columns = index_columns(conn, index_name) + requested_columns = tuple(columns) + if existing_columns is not None: + if existing_columns != requested_columns: + raise RuntimeError( + f"Index {index_name!r} already exists on columns " + f"{existing_columns}, expected {requested_columns}." + ) + print(f"Composite index already exists: {index_name}({', '.join(columns)})") + return + + columns_sql = ", ".join(quote_identifier(column) for column in columns) + create_sql = ( + f"CREATE INDEX {quote_identifier(index_name)} " + f"ON {quote_identifier(table)}({columns_sql})" + ) + print(f"Creating composite index: {index_name}({', '.join(columns)})", flush=True) + if dry_run: + print(f"Dry run SQL: {create_sql}") + return + + with sqlite_progress( + conn, + f"create {index_name}", + db_path=db_path, + temp_dir=temp_dir, + progress_mode=progress_mode, + ): + conn.execute(create_sql) + conn.commit() + print(f"Created composite index: {index_name}", flush=True) + + +def analyze_table( + conn: sqlite3.Connection, + *, + table: str, + db_path: Path, + temp_dir: Path, + progress_mode: str, + dry_run: bool, +) -> None: + analyze_sql = f"ANALYZE {quote_identifier(table)}" + print(f"Running ANALYZE for {table}", flush=True) + if dry_run: + print(f"Dry run SQL: {analyze_sql}") + return + + with sqlite_progress( + conn, + f"analyze {table}", + db_path=db_path, + temp_dir=temp_dir, + progress_mode=progress_mode, + ): + conn.execute(analyze_sql) + conn.commit() + print(f"Analyzed table: {table}", flush=True) + + +def main() -> int: + args = parse_args() + ensure_valid_args(args) + + print(f"DB: {args.db_path}") + print(f"Table: {args.table}") + print(f"Composite index: {args.index_name}({', '.join(args.columns)})") + + prepare_temp_dir(args.temp_dir, dry_run=args.dry_run) + with closing(connect_database(args.db_path, readonly=args.dry_run)) as conn: + ensure_table_has_columns(conn, args.table, args.columns) + missing_index = index_columns(conn, args.index_name) is None + if missing_index and not args.skip_preflight: + preflight_index_build( + conn, + table=args.table, + db_path=args.db_path, + temp_dir=args.temp_dir, + estimate_index_bytes_per_row=args.estimate_index_bytes_per_row, + ) + if args.dry_run: + print(f"SQLite worker threads: {args.threads} (planned)", flush=True) + else: + configure_sqlite_runtime(conn, threads=args.threads) + with sqlite_index_build_mode( + conn, + journal_mode=args.journal_mode, + dry_run=args.dry_run, + ): + create_composite_index( + conn, + table=args.table, + index_name=args.index_name, + columns=args.columns, + db_path=args.db_path, + temp_dir=args.temp_dir, + progress_mode=args.progress, + dry_run=args.dry_run, + ) + if args.analyze: + analyze_table( + conn, + table=args.table, + db_path=args.db_path, + temp_dir=args.temp_dir, + progress_mode=args.progress, + dry_run=args.dry_run, + ) + + if args.dry_run: + print("Dry run only; no indexes were changed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/era5_download/pipeline.py b/era5_pipeline/pipeline.py similarity index 69% rename from era5_download/pipeline.py rename to era5_pipeline/pipeline.py index a4d21e0..0a86960 100644 --- a/era5_download/pipeline.py +++ b/era5_pipeline/pipeline.py @@ -17,6 +17,13 @@ except ModuleNotFoundError: yaml = None +from config_utils import ( + PipelineDateRange, + PipelinePaths, + configured_date_range, + inferred_pipeline_paths, +) + SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent @@ -24,9 +31,6 @@ DEFAULT_STEPS = ( "download_era5", "process_era5", - "download_modis", - "assign_igbp_from_c1", - "transform_modis", ) @@ -45,17 +49,21 @@ class PipelineStep: name="process_era5", description="Convert ERA5 NetCDF chunks to the EcoPerceiver SQLite DB.", ), + "index_era5": PipelineStep( + name="index_era5", + description="Create persistent ec_data indexes for ERA5 eval and MODIS lookup.", + ), "download_modis": PipelineStep( name="download_modis", description="Download raw MODIS GeoTIFFs from Earth Engine.", ), - "assign_igbp_from_c1": PipelineStep( - name="assign_igbp_from_c1", - description="Assign coord_data.igbp from a MODIS C1 land-cover raster.", + "assign_igbp_from_modis": PipelineStep( + name="assign_igbp_from_modis", + description="Assign coord_data.igbp from a MODIS land-cover raster.", ), - "transform_modis": PipelineStep( - name="transform_modis", - description="Transform MODIS GeoTIFFs into modis_data SQLite rows.", + "process_modis": PipelineStep( + name="process_modis", + description="Process MODIS GeoTIFFs into modis_data SQLite rows.", ), } @@ -153,10 +161,12 @@ def parse_args() -> argparse.Namespace: config, resolved_config_path = load_config(config_args.config_path) pipeline_config = config_section(config, "pipeline") - path_config = config_section(config, "paths") + date_range = configured_date_range(config) + inferred_paths = inferred_pipeline_paths(config, REPO_ROOT) + index_era5_config = config_section(config, "index_era5") download_modis_config = config_section(config, "download_modis") - assign_igbp_config = config_section(config, "assign_igbp_from_c1") - transform_config = config_section(config, "transform_modis") + assign_igbp_config = config_section(config, "assign_igbp_from_modis") + process_modis_config = config_section(config, "process_modis") default_steps = config_list( pipeline_config.get("steps"), @@ -166,9 +176,9 @@ def parse_args() -> argparse.Namespace: validate_steps(default_steps) default_modis_dates = config_list( - transform_config.get("dates"), + process_modis_config.get("dates"), default=[], - field_name="transform_modis.dates", + field_name="process_modis.dates", ) parser = argparse.ArgumentParser( parents=[config_parser], @@ -205,12 +215,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--db-path", type=Path, - default=resolve_repo_path( - path_config.get( - "db_path", - pipeline_config.get("db_path", "experiments/data/era5.db"), - ) - ), + default=resolve_repo_path(pipeline_config.get("db_path")) or inferred_paths.db_path, help="SQLite DB used by ERA5/MODIS pipeline steps.", ) parser.add_argument( @@ -223,7 +228,7 @@ def parse_args() -> argparse.Namespace: "--era5-max-workers", type=int, default=None, - help="Override download.max_workers for concurrent ERA5 CDS downloads.", + help="Override download_era5.max_workers for concurrent ERA5 CDS downloads.", ) parser.add_argument( "--overwrite-era5-downloads", @@ -236,9 +241,10 @@ def parse_args() -> argparse.Namespace: help="Recreate the ERA5 SQLite DB during process_era5.", ) - add_download_modis_args(parser, download_modis_config) - add_assign_igbp_args(parser, assign_igbp_config) - add_transform_args(parser, transform_config) + add_index_era5_args(parser, index_era5_config, inferred_paths) + add_download_modis_args(parser, download_modis_config, date_range, inferred_paths) + add_assign_igbp_args(parser, assign_igbp_config, inferred_paths) + add_process_modis_args(parser, process_modis_config, inferred_paths) args = parser.parse_args() if args.modis_dates is None: @@ -249,25 +255,109 @@ def parse_args() -> argparse.Namespace: return args +def add_index_era5_args( + parser: argparse.ArgumentParser, + config: dict[str, Any], + inferred_paths: PipelinePaths, +) -> None: + group = parser.add_argument_group("index_era5 options") + group.add_argument( + "--era5-index-table", + default=config.get("table", "ec_data"), + help="ERA5 table to index.", + ) + group.add_argument( + "--era5-index-name", + default=config.get("index_name", "idx_ec_data_coord_id_timestamp_id"), + help="Composite index name used by ERA5 eval.", + ) + group.add_argument( + "--era5-index-columns", + nargs="+", + default=config_list( + config.get("columns"), + default=["coord_id", "timestamp", "id"], + field_name="index_era5.columns", + ), + help="Columns for the composite ERA5 eval index.", + ) + group.add_argument( + "--era5-index-analyze", + action=argparse.BooleanOptionalAction, + default=bool_config( + config.get("analyze"), + default=True, + field_name="index_era5.analyze", + ), + help="Run ANALYZE after creating the index.", + ) + group.add_argument( + "--era5-index-dry-run", + action=argparse.BooleanOptionalAction, + default=bool_config( + config.get("dry_run"), + default=False, + field_name="index_era5.dry_run", + ), + help="Run the index_era5 step in dry-run mode.", + ) + group.add_argument( + "--era5-index-temp-dir", + type=Path, + default=resolve_repo_path(config.get("temp_dir")) or inferred_paths.sqlite_temp_dir, + help="SQLite temporary sorter directory for the ERA5 index build.", + ) + group.add_argument( + "--era5-index-journal-mode", + choices=("DELETE", "TRUNCATE", "PERSIST", "WAL"), + default=config.get("journal_mode", "DELETE"), + help="SQLite journal mode to use while building the ERA5 index.", + ) + group.add_argument( + "--era5-index-skip-preflight", + action=argparse.BooleanOptionalAction, + default=bool_config( + config.get("skip_preflight"), + default=False, + field_name="index_era5.skip_preflight", + ), + help="Skip disk/page-count estimates before creating the ERA5 index.", + ) + group.add_argument( + "--era5-index-progress", + choices=("auto", "tqdm", "heartbeat", "none"), + default=config.get("progress", "auto"), + help="Progress display mode for the ERA5 index step.", + ) + group.add_argument( + "--era5-index-threads", + type=int, + default=config.get("threads", 8), + help="SQLite worker threads for the ERA5 index step.", + ) + + def add_download_modis_args( parser: argparse.ArgumentParser, config: dict[str, Any], + date_range: PipelineDateRange, + inferred_paths: PipelinePaths, ) -> None: group = parser.add_argument_group("download_modis options") group.add_argument( "--download-start-date", - default=config.get("start_date"), + default=config.get("start_date") or date_range.start.isoformat(), help="Optional MODIS download start date in YYYY-MM-DD format.", ) group.add_argument( "--download-end-date", - default=config.get("end_date"), + default=config.get("end_date") or date_range.end.isoformat(), help="Optional MODIS download end date in YYYY-MM-DD format.", ) group.add_argument( "--download-output-dir", type=Path, - default=resolve_repo_path(config.get("output_dir", "experiments/data/raw_modis")), + default=resolve_repo_path(config.get("output_dir")) or inferred_paths.raw_modis_dir, help="MODIS download output directory.", ) group.add_argument( @@ -310,14 +400,14 @@ def add_download_modis_args( def add_assign_igbp_args( parser: argparse.ArgumentParser, config: dict[str, Any], + inferred_paths: PipelinePaths, ) -> None: - group = parser.add_argument_group("assign_igbp_from_c1 options") + group = parser.add_argument_group("assign_igbp_from_modis options") group.add_argument( - "--igbp-c1-path", + "--igbp-modis-path", + dest="igbp_modis_path", type=Path, - default=resolve_repo_path( - config.get("c1_path", "experiments/data/raw_modis/201701011200C1.tiff") - ), + default=inferred_paths.modis_landcover_path, help="MODIS MCD12C1 GeoTIFF used to assign coord_data.igbp.", ) group.add_argument( @@ -331,7 +421,7 @@ def add_assign_igbp_args( default=bool_config( config.get("only_null"), default=False, - field_name="assign_igbp_from_c1.only_null", + field_name="assign_igbp_from_modis.only_null", ), help="Only assign coord_data rows where igbp is NULL.", ) @@ -341,7 +431,7 @@ def add_assign_igbp_args( default=bool_config( config.get("write"), default=True, - field_name="assign_igbp_from_c1.write", + field_name="assign_igbp_from_modis.write", ), help="Apply IGBP assignments. Use --no-igbp-write to run read-only.", ) @@ -349,19 +439,20 @@ def add_assign_igbp_args( "--igbp-batch-size", type=int, default=config.get("batch_size", 10_000), - help="SQLite assignment batch size for assign_igbp_from_c1.", + help="SQLite assignment batch size for assign_igbp_from_modis.", ) -def add_transform_args( +def add_process_modis_args( parser: argparse.ArgumentParser, config: dict[str, Any], + inferred_paths: PipelinePaths, ) -> None: - group = parser.add_argument_group("transform_modis options") + group = parser.add_argument_group("process_modis options") group.add_argument( "--modis-input-dir", type=Path, - default=resolve_repo_path(config.get("input_dir", "experiments/data/raw_modis")), + default=resolve_repo_path(config.get("input_dir")) or inferred_paths.raw_modis_dir, help="Raw MODIS GeoTIFF directory.", ) group.add_argument( @@ -370,7 +461,7 @@ def add_transform_args( action="append", default=None, help=( - "Specific MODIS timestamp to transform, e.g. 201712031200. " + "Specific MODIS timestamp to process, e.g. 201712031200. " "Repeat for multiple dates." ), ) @@ -378,13 +469,13 @@ def add_transform_args( "--limit-modis-dates", type=int, default=config.get("limit_dates"), - help="Limit the number of MODIS timestamp pairs transformed.", + help="Limit the number of MODIS timestamp pairs processed.", ) group.add_argument( "--modis-example-count", type=int, default=config.get("example_count", 10), - help="Number of transformed MODIS example cells to print.", + help="Number of processed MODIS example cells to print.", ) group.add_argument( "--overwrite-modis", @@ -392,7 +483,7 @@ def add_transform_args( default=bool_config( config.get("overwrite"), default=False, - field_name="transform_modis.overwrite", + field_name="process_modis.overwrite", ), help="Overwrite existing modis_data rows for matching coord/date pairs.", ) @@ -402,9 +493,9 @@ def add_transform_args( default=bool_config( config.get("active_ec_coords_only"), default=True, - field_name="transform_modis.active_ec_coords_only", + field_name="process_modis.active_ec_coords_only", ), - help="Only transform MODIS for coord_id values that remain in ec_data.", + help="Only process MODIS for coord_id values that remain in ec_data.", ) @@ -422,12 +513,14 @@ def command_for_step(step: str, args: argparse.Namespace) -> list[str]: return download_era5_command(args) if step == "process_era5": return process_era5_command(args) + if step == "index_era5": + return index_era5_command(args) if step == "download_modis": return download_modis_command(args) - if step == "assign_igbp_from_c1": - return assign_igbp_from_c1_command(args) - if step == "transform_modis": - return transform_modis_command(args) + if step == "assign_igbp_from_modis": + return assign_igbp_from_modis_command(args) + if step == "process_modis": + return process_modis_command(args) raise ValueError(f"Unsupported pipeline step: {step}") @@ -463,6 +556,33 @@ def process_era5_command(args: argparse.Namespace) -> list[str]: return command +def index_era5_command(args: argparse.Namespace) -> list[str]: + command = [ + args.python, + str(SCRIPT_DIR / "index_era5.py"), + "--db-path", + str(args.db_path), + "--table", + args.era5_index_table, + "--index-name", + args.era5_index_name, + "--columns", + *args.era5_index_columns, + ] + if not args.era5_index_analyze: + command.append("--no-analyze") + if args.era5_index_dry_run: + command.append("--dry-run") + if args.era5_index_temp_dir is not None: + command.extend(["--temp-dir", str(args.era5_index_temp_dir)]) + command.extend(["--journal-mode", args.era5_index_journal_mode]) + command.extend(["--progress", args.era5_index_progress]) + command.extend(["--threads", str(args.era5_index_threads)]) + if args.era5_index_skip_preflight: + command.append("--skip-preflight") + return command + + def download_modis_command(args: argparse.Namespace) -> list[str]: command = [ args.python, @@ -489,14 +609,14 @@ def download_modis_command(args: argparse.Namespace) -> list[str]: return command -def assign_igbp_from_c1_command(args: argparse.Namespace) -> list[str]: +def assign_igbp_from_modis_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(SCRIPT_DIR / "assign_igbp_from_c1.py"), + str(SCRIPT_DIR / "assign_igbp_from_modis.py"), "--db-path", str(args.db_path), - "--c1-path", - str(args.igbp_c1_path), + "--modis-path", + str(args.igbp_modis_path), "--table", args.igbp_table, "--batch-size", @@ -509,10 +629,10 @@ def assign_igbp_from_c1_command(args: argparse.Namespace) -> list[str]: return command -def transform_modis_command(args: argparse.Namespace) -> list[str]: +def process_modis_command(args: argparse.Namespace) -> list[str]: command = [ args.python, - str(SCRIPT_DIR / "transform_modis.py"), + str(SCRIPT_DIR / "process_modis.py"), "--input-dir", str(args.modis_input_dir), "--db-path", diff --git a/era5_download/pipeline.sh b/era5_pipeline/pipeline.sh similarity index 74% rename from era5_download/pipeline.sh rename to era5_pipeline/pipeline.sh index 4e4abdb..29c4a52 100755 --- a/era5_download/pipeline.sh +++ b/era5_pipeline/pipeline.sh @@ -4,16 +4,16 @@ #SBATCH --mem=128G #SBATCH --cpus-per-task=8 #SBATCH --time=24:00:00 -#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/logs/pipeline_%j.out -#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_download/logs/pipeline_%j.error -#SBATCH --job-name=era5-download +#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j.out +#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j.error +#SBATCH --job-name=era5-pipeline #SBATCH --account=aip-pal set -euo pipefail source "$SCRATCH/env/ecoperceiver/bin/activate" cd "$HOME/links/scratch/EcoPerceiver" -mkdir -p era5_download/logs +mkdir -p era5_pipeline/logs export PYTHONPATH=. export PYTHONUNBUFFERED=1 @@ -22,6 +22,6 @@ export MKL_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" export OPENBLAS_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" export NUMEXPR_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" -python -u era5_download/pipeline.py \ - --config-path era5_download/pipeline_config.yml \ +python -u era5_pipeline/pipeline.py \ + --config-path era5_pipeline/pipeline_config.yml \ "$@" diff --git a/era5_download/pipeline_config.yml b/era5_pipeline/pipeline_config.yml similarity index 67% rename from era5_download/pipeline_config.yml rename to era5_pipeline/pipeline_config.yml index b1c9457..8173866 100644 --- a/era5_download/pipeline_config.yml +++ b/era5_pipeline/pipeline_config.yml @@ -2,24 +2,21 @@ pipeline: steps: - download_era5 - process_era5 + # - index_era5 # - download_modis - # - assign_igbp_from_c1 - # - transform_modis + # - assign_igbp_from_modis + # - process_modis dry_run: false python: null -years: - - 2016 - - 2017 +start_date: "2016-01-01" +end_date: "2017-12-31" paths: - output_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017 - db_path: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_2016_2017.db - netcdf_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_data - zip_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/era5_zip + data_root: /home/l/luislara/links/projects/aip-pal/luislara/ep/data lsm_path: experiments/data/lsm.nc -download: +download_era5: enabled: true dataset: reanalysis-era5-single-levels product_type: reanalysis @@ -54,12 +51,10 @@ download: - friction_velocity - geopotential -processing: +process_era5: recreate_db: true batch_size: 50000 xarray_engine: h5netcdf - create_index: true - sqlite_temp_dir: null sqlite_threads: 8 timezone: @@ -72,8 +67,6 @@ processing: fallback: longitude_quarter_hour local_window: enabled: true - start: "2016-01-01 00:00:00" - end: "2017-12-31 23:59:59" land_sea_mask: enabled: true @@ -81,24 +74,34 @@ processing: include_land_neighbors: true allow_missing: false +index_era5: + table: ec_data + index_name: idx_ec_data_coord_id_timestamp_id + columns: + - coord_id + - timestamp + - id + journal_mode: DELETE + skip_preflight: false + progress: auto + threads: 8 + analyze: true + dry_run: false + download_modis: - start_date: "2016-01-01" - end_date: "2017-12-31" - output_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/raw_modis products: null max_workers: 8 overwrite: false authenticate: false -assign_igbp_from_c1: - c1_path: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/raw_modis/201701011200C1.tiff +assign_igbp_from_modis: + modis_path: 201701011200C1.tiff table: coord_data only_null: false write: true batch_size: 10000 -transform_modis: - input_dir: /home/l/luislara/links/projects/aip-pal/luislara/ep/data/2016_2017/raw_modis +process_modis: dates: [] limit_dates: null example_count: 10 diff --git a/era5_download/transform_modis.py b/era5_pipeline/process_modis.py similarity index 96% rename from era5_download/transform_modis.py rename to era5_pipeline/process_modis.py index 5207e04..35f65e0 100644 --- a/era5_download/transform_modis.py +++ b/era5_pipeline/process_modis.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Transform raw global MODIS rasters into 9x8x8 arrays per 0.25 degree cell. +"""Process raw global MODIS rasters into 9x8x8 arrays per 0.25 degree cell. This script follows the cleaning logic used in CarbonSense/EcoPerceiver stage 3: @@ -15,7 +15,7 @@ pixels `[1:9, 1:9]`. It chunks the full global rasters directly into the 720 x 1440 ERA5-style grid, yielding one 9 x 8 x 8 tensor per cell. -Each transformed cell is inserted into `modis_data(coord_id, modis_date, data)` +Each processed cell is inserted into `modis_data(coord_id, modis_date, data)` in the target SQLite database. """ @@ -172,7 +172,7 @@ def ensure_dependencies() -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( - "Transform paired raw MODIS A4/A2 GeoTIFFs into one 9x8x8 tensor " + "Process paired raw MODIS A4/A2 GeoTIFFs into one 9x8x8 tensor " "per 0.25 degree grid cell and insert them into SQLite." ) ) @@ -196,7 +196,7 @@ def parse_args() -> argparse.Namespace: action="append", default=[], help=( - "Specific timestamp(s) to transform, e.g. 201712031200. " + "Specific timestamp(s) to process, e.g. 201712031200. " "Repeat for multiple dates." ), ) @@ -210,7 +210,7 @@ def parse_args() -> argparse.Namespace: "--example-count", type=int, default=10, - help="Number of random transformed example cells to print. Default: 10.", + help="Number of random processed example cells to print. Default: 10.", ) parser.add_argument( "--overwrite", @@ -351,8 +351,8 @@ def load_coord_lookup( if not index_has_leading_column(conn, table="ec_data", column="coord_id"): raise RuntimeError( "`--active-ec-coords-only` requires an ec_data index whose first " - "column is coord_id. Run the ERA5 process stage first so " - "download_era5.py creates idx_ec_data_coord_id_timestamp_id, or pass " + "column is coord_id. Run the index_era5 pipeline step first to " + "create idx_ec_data_coord_id_timestamp_id, or pass " "`--no-active-ec-coords-only`." ) @@ -643,7 +643,7 @@ def main() -> int: print( f"Found {len(pairs)} paired timestamp(s) in {args.input_dir}. " - f"Transforming into {EXPECTED_GRID_HEIGHT}x{EXPECTED_GRID_WIDTH} cells " + f"Processing into {EXPECTED_GRID_HEIGHT}x{EXPECTED_GRID_WIDTH} cells " f"of shape 9x8x8.", flush=True, ) @@ -674,7 +674,7 @@ def main() -> int: progress = tqdm( pairs, total=len(pairs), - desc="transform_modis", + desc="process_modis", unit="date", dynamic_ncols=True, file=sys.stdout, @@ -700,7 +700,7 @@ def main() -> int: ) else: print( - f"transform_modis: {date_index}/{len(pairs)} date " + f"process_modis: {date_index}/{len(pairs)} date " f"timestamp={pair.timestamp} matched={stats.matched_coords} " f"wrote={stats.inserted_or_updated_rows}", flush=True, From 1ca7196da028118c92b5135695fce1205f595b07 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Wed, 24 Jun 2026 15:04:15 -0400 Subject: [PATCH 07/14] process_era5 optimized --- era5_pipeline/download_era5.py | 420 ++++++++++++++++++++---------- era5_pipeline/pipeline_config.yml | 7 +- 2 files changed, 292 insertions(+), 135 deletions(-) diff --git a/era5_pipeline/download_era5.py b/era5_pipeline/download_era5.py index 271665d..54b03bd 100644 --- a/era5_pipeline/download_era5.py +++ b/era5_pipeline/download_era5.py @@ -15,7 +15,7 @@ import argparse import calendar from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timedelta, timezone import json import math @@ -783,8 +783,6 @@ def init_sqlite(conn: sqlite3.Connection, config: dict[str, Any]) -> None: """, [(key, json.dumps(value, sort_keys=True)) for key, value in metadata.items()], ) - conn.execute("CREATE INDEX IF NOT EXISTS idx_coord_data_lat_lon ON coord_data(lat, lon)") - def normalize_longitudes(values) -> np.ndarray: arr = np.asarray(values, dtype=float) @@ -806,7 +804,7 @@ def __init__(self, config: dict[str, Any]): self.fallback = str(timezone_config.get("fallback", "longitude_quarter_hour")).lower() self.require_timezonefinder = bool(timezone_config.get("require_timezonefinder", True)) self.zone_cache: dict[tuple[float, float], str | None] = {} - self.offset_cache: dict[tuple[str | None, datetime, float], int] = {} + self.zone_offset_cache: dict[tuple[str, datetime], int | None] = {} self.timezone_finder = None if self.enabled and self.method == "timezonefinder": if TimezoneFinder is None: @@ -820,73 +818,6 @@ def __init__(self, config: dict[str, Any]): else: self.timezone_finder = TimezoneFinder() - def localize_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> "pd.DataFrame": - if not self.enabled: - return self._assign_utc_time(df, utc_timestamp) - - utc_ts = pd.Timestamp(utc_timestamp) - if utc_ts.tzinfo is None: - utc_ts = utc_ts.tz_localize("UTC") - else: - utc_ts = utc_ts.tz_convert("UTC") - utc_dt = utc_ts.to_pydatetime() - - coord_offsets = self.offset_minutes_for_coordinates( - df[["lat", "lon"]].drop_duplicates(), - utc_dt, - ) - keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) - offset_minutes = keys.map(coord_offsets).to_numpy(dtype=np.int32) - - utc_naive = utc_ts.tz_localize(None) - local_datetimes = utc_naive + pd.to_timedelta(offset_minutes, unit="m") - if isinstance(local_datetimes, pd.Timestamp): - local_series = pd.Series([local_datetimes] * len(df), index=df.index) - else: - local_series = pd.Series(local_datetimes, index=df.index) - - df["_local_timestamp"] = datetimes_to_int(local_series) - if self.timestamp_policy == "local": - df["timestamp"] = df["_local_timestamp"] - else: - df["timestamp"] = datetimes_to_int(pd.Series(utc_naive, index=df.index)) - df["DOY"] = local_series.dt.dayofyear.astype(float) - tod = ( - local_series.dt.hour.astype(float) - + local_series.dt.minute.astype(float) / 60.0 - + local_series.dt.second.astype(float) / 3600.0 - + 1.0 - ) - df["TOD"] = tod.where(tod <= 24.0, tod - 24.0) - return df - - def _assign_utc_time(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> "pd.DataFrame": - utc_ts = pd.Timestamp(utc_timestamp) - if utc_ts.tzinfo is not None: - utc_ts = utc_ts.tz_convert("UTC").tz_localize(None) - df["timestamp"] = int(utc_ts.strftime("%Y%m%d%H%M%S")) - df["_local_timestamp"] = df["timestamp"] - df["DOY"] = float(utc_ts.dayofyear) - df["TOD"] = float(utc_ts.hour + 1) - return df - - def offset_minutes_for_coordinates( - self, - coords: "pd.DataFrame", - utc_dt: datetime, - ) -> dict[tuple[float, float], int]: - offsets: dict[tuple[float, float], int] = {} - for row in coords.itertuples(index=False): - lat = float(row.lat) - lon = float(row.lon) - key = rounded_coord_key(lat, lon) - zone_name = self.zone_for_coordinate(lat, lon) - cache_key = (zone_name, utc_dt.replace(tzinfo=timezone.utc), round(lon, 6)) - if cache_key not in self.offset_cache: - self.offset_cache[cache_key] = self.offset_for_zone(zone_name, lon, utc_dt) - offsets[key] = self.offset_cache[cache_key] - return offsets - def zone_for_coordinate(self, lat: float, lon: float) -> str | None: key = rounded_coord_key(lat, lon) if key in self.zone_cache: @@ -899,25 +830,28 @@ def zone_for_coordinate(self, lat: float, lon: float) -> str | None: self.zone_cache[key] = zone_name return zone_name - def offset_for_zone(self, zone_name: str | None, lon: float, utc_dt: datetime) -> int: - if zone_name: - try: - local_dt = utc_dt.astimezone(ZoneInfo(zone_name)) - offset = local_dt.utcoffset() - if offset is not None: - return int(offset.total_seconds() // 60) - except ZoneInfoNotFoundError: - pass - if self.fallback in {"longitude_quarter_hour", "longitude"}: - minutes = lon * 4.0 - if self.fallback == "longitude_quarter_hour": - minutes = round(minutes / 15.0) * 15.0 - return int(max(-12 * 60, min(14 * 60, minutes))) - return 0 - - -def datetimes_to_int(values: "pd.Series") -> "pd.Series": - return values.dt.strftime("%Y%m%d%H%M%S").astype("int64") + def fallback_offsets_for_longitudes(self, lons: np.ndarray) -> np.ndarray: + if self.fallback not in {"longitude_quarter_hour", "longitude"}: + return np.zeros(len(lons), dtype=np.int32) + minutes = np.asarray(lons, dtype=float) * 4.0 + if self.fallback == "longitude_quarter_hour": + minutes = np.round(minutes / 15.0) * 15.0 + minutes = np.clip(minutes, -12 * 60, 14 * 60) + return minutes.astype(np.int32) + + def offset_for_named_zone(self, zone_name: str, utc_dt: datetime) -> int | None: + utc_key = utc_dt.replace(tzinfo=timezone.utc) + cache_key = (zone_name, utc_key) + if cache_key in self.zone_offset_cache: + return self.zone_offset_cache[cache_key] + try: + local_dt = utc_dt.astimezone(ZoneInfo(zone_name)) + offset = local_dt.utcoffset() + minutes = int(offset.total_seconds() // 60) if offset is not None else None + except ZoneInfoNotFoundError: + minutes = None + self.zone_offset_cache[cache_key] = minutes + return minutes class LandSeaMask: @@ -1014,21 +948,32 @@ def load_existing_coords(self) -> None: self.coord_lookup[rounded_coord_key(lat, lon)] = int(coord_id) self.next_coord_id = max(self.next_coord_id, int(coord_id) + 1) - def assign_coord_ids(self, df: "pd.DataFrame") -> "pd.DataFrame": - coord_rows = ( - df[["lat", "lon", "elev", "igbp"]] - .drop_duplicates(subset=["lat", "lon"]) - .reset_index(drop=True) - ) + def coord_ids_for_arrays( + self, + lat: np.ndarray, + lon: np.ndarray, + elev: np.ndarray, + igbp: str | None = None, + ) -> np.ndarray: + coord_ids = np.empty(len(lat), dtype=np.int64) inserts = [] - for row in coord_rows.itertuples(index=False): - key = rounded_coord_key(row.lat, row.lon) - if key in self.coord_lookup: - continue - coord_id = self.next_coord_id - self.next_coord_id += 1 - self.coord_lookup[key] = coord_id - inserts.append((coord_id, float(row.lat), float(row.lon), nullable_float(row.elev), row.igbp)) + for index, (row_lat, row_lon, row_elev) in enumerate(zip(lat, lon, elev, strict=True)): + key = rounded_coord_key(row_lat, row_lon) + coord_id = self.coord_lookup.get(key) + if coord_id is None: + coord_id = self.next_coord_id + self.next_coord_id += 1 + self.coord_lookup[key] = coord_id + inserts.append( + ( + coord_id, + float(row_lat), + float(row_lon), + nullable_float(row_elev), + igbp, + ) + ) + coord_ids[index] = coord_id if inserts: self.conn.executemany( """ @@ -1038,9 +983,7 @@ def assign_coord_ids(self, df: "pd.DataFrame") -> "pd.DataFrame": """, inserts, ) - keys = pd.MultiIndex.from_frame(df[["lat", "lon"]].round(6)) - df["coord_id"] = keys.map(self.coord_lookup).astype("int64") - return df + return coord_ids def insert_ec_data(self, df: "pd.DataFrame") -> int: for predictor in EC_PREDICTORS: @@ -1073,11 +1016,18 @@ class Era5GroupGrid: lon: np.ndarray land_indices: np.ndarray spatial_shape: tuple[int, int] + fallback_offsets: np.ndarray + timezone_groups: tuple[tuple[str, np.ndarray], ...] + coord_id: np.ndarray | None = None @dataclass(frozen=True) class Era5TimeBlock: variables: dict[str, np.ndarray] + timestamp: np.ndarray + local_timestamp: np.ndarray + doy: np.ndarray + tod: np.ndarray length: int @@ -1089,11 +1039,18 @@ def __init__(self, config: dict[str, Any], conn: sqlite3.Connection): self.batch_size = int(process_config.get("batch_size", 50_000)) time_block_size = process_config.get("time_block_size") self.time_block_size = int(time_block_size) if time_block_size else None + self.insert_hours_per_batch = max( + 1, + int(process_config.get("insert_hours_per_batch", 4)), + ) self.xarray_engine = process_config.get("xarray_engine") self.timezone_resolver = TimezoneResolver(config) self.land_mask = LandSeaMask(config) self.writer = Era5DatabaseWriter(conn, self.batch_size) self.local_window = self.parse_local_window() + self.local_window_int = tuple( + self.timestamp_bound_to_int(bound) for bound in self.local_window + ) def parse_local_window(self) -> tuple["pd.Timestamp | None", "pd.Timestamp | None"]: timezone_config = section(section(self.config, "process_era5"), "timezone") @@ -1105,6 +1062,15 @@ def parse_local_window(self) -> tuple["pd.Timestamp | None", "pd.Timestamp | Non end = pd.Timestamp(window_config.get("end") or date_range.local_window_end) return start, end + @staticmethod + def timestamp_bound_to_int(timestamp: "pd.Timestamp | None") -> int | None: + if timestamp is None: + return None + ts = pd.Timestamp(timestamp) + if ts.tzinfo is not None: + ts = ts.tz_convert("UTC").tz_localize(None) + return int(ts.strftime("%Y%m%d%H%M%S")) + def process_groups(self, netcdf_dir: Path, limit_groups: int | None = None) -> int: group_dirs = list(iter_netcdf_group_dirs(netcdf_dir)) if limit_groups is not None: @@ -1153,12 +1119,39 @@ def process_group(self, group_dir: Path) -> int: group_grid = self.prepare_group_grid(ds) valid_times = pd.to_datetime(ds["valid_time"].values) time_block_size = self.infer_time_block_size(ds, len(valid_times)) + pending_frames: list[pd.DataFrame] = [] + + def flush_pending_frames() -> None: + nonlocal inserted + if not pending_frames: + return + if len(pending_frames) == 1: + batch = pending_frames[0] + else: + batch = pd.concat(pending_frames, ignore_index=True, copy=False) + inserted += self.writer.insert_ec_data(batch) + pending_frames.clear() + for block_start in range(0, len(valid_times), time_block_size): block_end = min(block_start + time_block_size, len(valid_times)) - block = self.land_block_from_dataset(ds, group_grid, block_start, block_end) - for block_offset, valid_time in enumerate(valid_times[block_start:block_end]): + block = self.land_block_from_dataset( + ds, + group_grid, + valid_times[block_start:block_end], + block_start, + block_end, + ) + if group_grid.coord_id is None: + group_grid = self.prepare_group_coord_ids(group_grid, block, valid_times) + for block_offset in range(block.length): frame = self.land_frame_from_block(group_grid, block, block_offset) - inserted += self.process_land_frame(frame, pd.Timestamp(valid_time)) + frame = self.prepare_land_frame(frame) + if frame.empty: + continue + pending_frames.append(frame) + if len(pending_frames) >= self.insert_hours_per_batch: + flush_pending_frames() + flush_pending_frames() self.conn.commit() return inserted finally: @@ -1176,13 +1169,101 @@ def prepare_group_grid(self, ds) -> Era5GroupGrid: coord_frame = pd.DataFrame({"lat": lat_flat, "lon": lon_flat}) land_frame = self.land_mask.filter(coord_frame) land_indices = land_frame.index.to_numpy(dtype=np.int64) + land_lat = lat_flat[land_indices] + land_lon = lon_flat[land_indices] return Era5GroupGrid( - lat=lat_flat[land_indices], - lon=lon_flat[land_indices], + lat=land_lat, + lon=land_lon, land_indices=land_indices, spatial_shape=(len(lats), len(lons)), + fallback_offsets=self.timezone_resolver.fallback_offsets_for_longitudes(land_lon), + timezone_groups=self.timezone_groups_for_coordinates(land_lat, land_lon), + ) + + def timezone_groups_for_coordinates( + self, + lat: np.ndarray, + lon: np.ndarray, + ) -> tuple[tuple[str, np.ndarray], ...]: + if not self.timezone_resolver.enabled: + return () + grouped: dict[str, list[int]] = {} + for index, (row_lat, row_lon) in enumerate(zip(lat, lon, strict=True)): + zone_name = self.timezone_resolver.zone_for_coordinate(float(row_lat), float(row_lon)) + if zone_name is None: + continue + grouped.setdefault(zone_name, []).append(index) + return tuple( + (zone_name, np.asarray(indices, dtype=np.int64)) + for zone_name, indices in grouped.items() ) + def prepare_group_coord_ids( + self, + group_grid: Era5GroupGrid, + block: Era5TimeBlock, + valid_times: Iterable["pd.Timestamp"], + ) -> Era5GroupGrid: + if "geopotential" in block.variables: + elev = block.variables["geopotential"][0] / GRAVITATIONAL_ACC + else: + elev = np.full(len(group_grid.lat), np.nan, dtype=float) + coord_id = np.full(len(group_grid.lat), -1, dtype=np.int64) + insert_order = self.coord_insert_order_for_times(group_grid, valid_times) + ordered_coord_ids = self.writer.coord_ids_for_arrays( + group_grid.lat[insert_order], + group_grid.lon[insert_order], + elev[insert_order], + ) + coord_id[insert_order] = ordered_coord_ids + return replace(group_grid, coord_id=coord_id) + + def coord_insert_order_for_times( + self, + group_grid: Era5GroupGrid, + valid_times: Iterable["pd.Timestamp"], + ) -> np.ndarray: + point_count = len(group_grid.lat) + start, end = self.local_window_int + if start is None and end is None: + return np.arange(point_count, dtype=np.int64) + + first_seen = np.full(point_count, -1, dtype=np.int32) + valid_time_list = [pd.Timestamp(valid_time) for valid_time in valid_times] + for time_index, utc_ts in enumerate(valid_time_list): + if utc_ts.tzinfo is None: + utc_ts = utc_ts.tz_localize("UTC") + else: + utc_ts = utc_ts.tz_convert("UTC") + utc_dt = utc_ts.to_pydatetime() + utc_naive = utc_ts.tz_localize(None) + offset_minutes = ( + self.offset_minutes_for_group_grid(group_grid, utc_dt) + if self.timezone_resolver.enabled + else np.zeros(point_count, dtype=np.int32) + ) + keep = np.zeros(point_count, dtype=bool) + for offset in np.unique(offset_minutes): + local_ts = utc_naive + pd.Timedelta(minutes=int(offset)) + local_timestamp_value = int(local_ts.strftime("%Y%m%d%H%M%S")) + if start is not None and local_timestamp_value < start: + continue + if end is not None and local_timestamp_value > end: + continue + keep |= offset_minutes == offset + newly_seen = keep & (first_seen < 0) + first_seen[newly_seen] = time_index + if np.all(first_seen >= 0): + break + + ordered_indices = [ + np.flatnonzero(first_seen == time_index) + for time_index in range(len(valid_time_list)) + ] + if not ordered_indices: + return np.array([], dtype=np.int64) + return np.concatenate(ordered_indices).astype(np.int64, copy=False) + def infer_time_block_size(self, ds, valid_time_count: int) -> int: if self.time_block_size is not None: return max(1, min(int(self.time_block_size), valid_time_count)) @@ -1203,6 +1284,7 @@ def land_block_from_dataset( self, ds, group_grid: Era5GroupGrid, + valid_times: Iterable["pd.Timestamp"], block_start: int, block_end: int, ) -> Era5TimeBlock: @@ -1248,7 +1330,80 @@ def land_block_from_dataset( land_values, (block_length, len(group_grid.land_indices)), ) - return Era5TimeBlock(variables=variables, length=block_length) + timestamp, local_timestamp, doy, tod = self.time_fields_for_block( + group_grid, + valid_times, + ) + return Era5TimeBlock( + variables=variables, + timestamp=timestamp, + local_timestamp=local_timestamp, + doy=doy, + tod=tod, + length=block_length, + ) + + def time_fields_for_block( + self, + group_grid: Era5GroupGrid, + valid_times: Iterable["pd.Timestamp"], + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + valid_time_list = [pd.Timestamp(valid_time) for valid_time in valid_times] + block_length = len(valid_time_list) + point_count = len(group_grid.land_indices) + timestamp = np.empty((block_length, point_count), dtype=np.int64) + local_timestamp = np.empty((block_length, point_count), dtype=np.int64) + doy = np.empty((block_length, point_count), dtype=float) + tod = np.empty((block_length, point_count), dtype=float) + + for time_index, utc_ts in enumerate(valid_time_list): + if utc_ts.tzinfo is None: + utc_ts = utc_ts.tz_localize("UTC") + else: + utc_ts = utc_ts.tz_convert("UTC") + utc_dt = utc_ts.to_pydatetime() + utc_naive = utc_ts.tz_localize(None) + utc_timestamp = int(utc_naive.strftime("%Y%m%d%H%M%S")) + + if self.timezone_resolver.enabled: + offset_minutes = self.offset_minutes_for_group_grid(group_grid, utc_dt) + else: + offset_minutes = np.zeros(point_count, dtype=np.int32) + + for offset in np.unique(offset_minutes): + offset_mask = offset_minutes == offset + local_ts = utc_naive + pd.Timedelta(minutes=int(offset)) + local_timestamp_value = int(local_ts.strftime("%Y%m%d%H%M%S")) + tod_value = ( + float(local_ts.hour) + + float(local_ts.minute) / 60.0 + + float(local_ts.second) / 3600.0 + + 1.0 + ) + if tod_value > 24.0: + tod_value -= 24.0 + local_timestamp[time_index, offset_mask] = local_timestamp_value + doy[time_index, offset_mask] = float(local_ts.dayofyear) + tod[time_index, offset_mask] = tod_value + + if self.timezone_resolver.timestamp_policy == "local": + timestamp[time_index] = local_timestamp[time_index] + else: + timestamp[time_index].fill(utc_timestamp) + + return timestamp, local_timestamp, doy, tod + + def offset_minutes_for_group_grid( + self, + group_grid: Era5GroupGrid, + utc_dt: datetime, + ) -> np.ndarray: + offsets = group_grid.fallback_offsets.copy() + for zone_name, indices in group_grid.timezone_groups: + zone_offset = self.timezone_resolver.offset_for_named_zone(zone_name, utc_dt) + if zone_offset is not None: + offsets[indices] = zone_offset + return offsets def land_frame_from_block( self, @@ -1256,28 +1411,29 @@ def land_frame_from_block( block: Era5TimeBlock, block_offset: int, ) -> "pd.DataFrame": - data: dict[str, np.ndarray] = { - "lat": group_grid.lat, - "lon": group_grid.lon, - } + if group_grid.coord_id is None: + raise RuntimeError("ERA5 group coord_id values were not prepared before frame creation.") if block_offset < 0 or block_offset >= block.length: raise IndexError(f"Block offset {block_offset} is outside block length {block.length}.") + data: dict[str, np.ndarray] = { + "coord_id": group_grid.coord_id, + "timestamp": block.timestamp[block_offset], + "_local_timestamp": block.local_timestamp[block_offset], + "DOY": block.doy[block_offset], + "TOD": block.tod[block_offset], + } for name, values in block.variables.items(): data[name] = values[block_offset] return pd.DataFrame(data, copy=False) - def process_land_frame(self, df: "pd.DataFrame", utc_timestamp: "pd.Timestamp") -> int: + def prepare_land_frame(self, df: "pd.DataFrame") -> "pd.DataFrame": if df.empty: - return 0 - df = self.add_predictors(df) - df["igbp"] = None - df = self.timezone_resolver.localize_frame(df, utc_timestamp) + return df df = self.apply_local_window(df) if df.empty: - return 0 - df = minmax_normalization(df) - df = self.writer.assign_coord_ids(df) - return self.writer.insert_ec_data(df) + return df + df = self.add_predictors(df) + return minmax_normalization(df) def add_predictors(self, df: "pd.DataFrame") -> "pd.DataFrame": for predictor in [*EC_PREDICTORS, "ELEVATION"]: @@ -1301,17 +1457,15 @@ def add_predictors(self, df: "pd.DataFrame") -> "pd.DataFrame": return df def apply_local_window(self, df: "pd.DataFrame") -> "pd.DataFrame": - start, end = self.local_window + start, end = self.local_window_int if start is None and end is None: return df - timestamp_source = "_local_timestamp" if "_local_timestamp" in df.columns else "timestamp" - timestamp_text = df[timestamp_source].astype("int64").astype(str) - local_dt = pd.to_datetime(timestamp_text, format="%Y%m%d%H%M%S") - keep = pd.Series(True, index=df.index) + local_timestamp = df["_local_timestamp"].to_numpy(dtype=np.int64) + keep = np.ones(len(df), dtype=bool) if start is not None: - keep &= local_dt >= start + keep &= local_timestamp >= start if end is not None: - keep &= local_dt <= end + keep &= local_timestamp <= end return df.loc[keep].copy() diff --git a/era5_pipeline/pipeline_config.yml b/era5_pipeline/pipeline_config.yml index 8173866..98c7532 100644 --- a/era5_pipeline/pipeline_config.yml +++ b/era5_pipeline/pipeline_config.yml @@ -1,6 +1,6 @@ pipeline: steps: - - download_era5 + # - download_era5 - process_era5 # - index_era5 # - download_modis @@ -11,6 +11,8 @@ pipeline: start_date: "2016-01-01" end_date: "2017-12-31" +# start_date: "2014-01-01" +# end_date: "2015-12-31" paths: data_root: /home/l/luislara/links/projects/aip-pal/luislara/ep/data @@ -53,7 +55,8 @@ download_era5: process_era5: recreate_db: true - batch_size: 50000 + batch_size: 100000 + insert_hours_per_batch: 24 xarray_engine: h5netcdf sqlite_threads: 8 From 40b68f0e9801f48381baee6653824ba53379cb7f Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Fri, 26 Jun 2026 13:14:21 -0400 Subject: [PATCH 08/14] plot verifiers added --- .gitignore | 1 - era5_pipeline/index_era5.py | 11 +- era5_pipeline/pipeline_config.yml | 10 +- era5_pipeline/plot/igbp_coord_map.png | Bin 0 -> 94039 bytes .../plot}/plot_igbp_map.py | 2 +- era5_pipeline/plot/plot_raw_era5_holes.py | 713 ++++++++++++++++++ ...holes_20170103090000_vs_20170103080000.png | Bin 0 -> 69145 bytes ...holes_20170103090000_vs_20170103080000.txt | 17 + ...20170103090000_vs_20170103080000_holes.csv | 1 + .../plot}/viz_netcdf_structure.py | 0 scripts/run.sh | 13 + scripts/run_era5_one_year_inference.sh | 4 +- 12 files changed, 761 insertions(+), 11 deletions(-) create mode 100644 era5_pipeline/plot/igbp_coord_map.png rename {scripts => era5_pipeline/plot}/plot_igbp_map.py (99%) create mode 100644 era5_pipeline/plot/plot_raw_era5_holes.py create mode 100644 era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.png create mode 100644 era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt create mode 100644 era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv rename {scripts => era5_pipeline/plot}/viz_netcdf_structure.py (100%) create mode 100644 scripts/run.sh diff --git a/.gitignore b/.gitignore index 84bd984..e72fdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -172,7 +172,6 @@ experiments/tensorboard OLD_*/ logs/ scripts/modis/logs/ -scripts/run.sh scripts/run_era5.sh era5_predictions.csv diff --git a/era5_pipeline/index_era5.py b/era5_pipeline/index_era5.py index c9b7170..3c11fdf 100644 --- a/era5_pipeline/index_era5.py +++ b/era5_pipeline/index_era5.py @@ -23,8 +23,8 @@ DEFAULT_TABLE = "ec_data" DEFAULT_INDEX_NAME = "idx_ec_data_coord_id_timestamp_id" DEFAULT_INDEX_COLUMNS = ("coord_id", "timestamp", "id") -SQLITE_PROGRESS_OPCODES = 100_000 -HEARTBEAT_SECONDS = 15.0 +SQLITE_PROGRESS_OPCODES = 10_000_000 +HEARTBEAT_SECONDS = 60.0 PROGRESS_STATUS_SECONDS = 2.0 SQLITE_TIMEOUT_SECONDS = 60.0 ESTIMATED_INDEX_BYTES_PER_ROW = 32.0 @@ -235,6 +235,13 @@ def sqlite_progress( temp_dir=temp_dir, progress_mode=progress_mode, ) + if progress.progress_mode == "none": + try: + yield + finally: + progress.close() + return + conn.set_progress_handler(progress.update, SQLITE_PROGRESS_OPCODES) try: yield diff --git a/era5_pipeline/pipeline_config.yml b/era5_pipeline/pipeline_config.yml index 98c7532..d9573d4 100644 --- a/era5_pipeline/pipeline_config.yml +++ b/era5_pipeline/pipeline_config.yml @@ -1,11 +1,11 @@ pipeline: steps: # - download_era5 - - process_era5 - # - index_era5 + # - process_era5 # - download_modis - # - assign_igbp_from_modis - # - process_modis + - index_era5 + - assign_igbp_from_modis + - process_modis dry_run: false python: null @@ -86,7 +86,7 @@ index_era5: - id journal_mode: DELETE skip_preflight: false - progress: auto + progress: none threads: 8 analyze: true dry_run: false diff --git a/era5_pipeline/plot/igbp_coord_map.png b/era5_pipeline/plot/igbp_coord_map.png new file mode 100644 index 0000000000000000000000000000000000000000..16f78a397ff4ff8e74bc20ae7cd76a7b30e956f1 GIT binary patch literal 94039 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 z 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%0DJipI>Il65 zLn3Qh&%g`DS{9mrqQXwEqmQl;M)4U&{PYX0pJl1{TeG!jKUFu30fXdy`p@BUJO!F zQa~vEIz2s@sal4GyiM_tJMhBW_ZVVm%_ih?Rtv3e|0T@<{A!}Ja}^Zmg%r3f6+w^> zK=y;U_LxcNz>c1nn83s((>5>=h7z2V*9H+B7Z=yw{tw{Q_vI=T@)I_F>%IX4xfFtJJUqN3u8v6~wvqw5qYKuzV}H1te=18JfU z*4)y=jX{{$>>p8jcUrJ2S9K}Uq5 zmM+68yOP>!oWu$}B7P`+wCajW{0JM=g(~7F%}6ErH2Izo&lJf(W+lI(gjwSef0C0t zk$?vi6+fof?6_#Ms6z}eg(Q}G45v>kt$6s!MqG%M9D49#wuK!}o}9hxn!bgx&x%vR zNn>rEJ2<%97=qJ0=wm3n^)8hJIT2*|kiyZ)(<&9+r#gXy2NjEF70;t+A}Y5xO~C1~ zz=@;XB>Y^CMefZLH?rTDOwu)v_ZJJMTlQ^uXkACjv^^6GV@~t34g=UEBV@=)OoxGa z7~$99pzJ|A4Gj%_{rWX9X^?&APAZVX4G#!XSyk1jBM~ui12hB&x7ybBc2jdR1}5gt z_I5S7sQ({l*n=P(1;iQm)~%hT%S8hhXmSzz;>C3%acG_hIBB!jfw)y4K14^FM-g=n zkrG1qnmh(u+5X91XEr|B&-cX!zEjz~DmX*4D)T{RUhl>VYFq?P2DKVS*$YpUT04DB zyC;*J8h~IL>#*#3s3xRGc)bNRoUvp?Tc@I?pXJ4m3b8QKjecr7sN>3hV|cRCN!>aR z)sx|@+;GwYQm!U)5onnRbAb4Fg%c|^E!%_c-44=yXXFDw6QEwVDTTG^39==E0>SkR z_tlG6ucp3yfk;dxN~T5$cp zVt$@RMp@0x!U!nCYzC59z_16fe(Uq+&oeR%Y-~2JTc<*|O=uGtc-<-*?cgkkP6^0PN0S@`K`cXh!q|t|Pd+o+{)q;X0)C|-`w@Ohu--D7IcYxQjn+U{H_ zE}6oud-om=W@stA@uYB+f4XaZ)a+kNVQ=$%((*Q+WZEXj=jaWF4^Dmffl_r<`J3vc z!}bm-3@e$zt0Cw6iwn1vfX6`KV>`6f!o_WDVDMvOLyr96!-wvB^S^;?;&ZMlFGoex zd;^OySzIp+Q(@Fy-`wmv9}GebhA389S(zo4tl^I-L3*d0@GPg4m~20uIZVtlB_UaG zAvu{SgvMHHC%bGRV}hE5pI7b-#o!ok78lN=rk&^ExHd;XZbiTH1fa}y_^I=B%GYY*&u!W!u+@ba!F@}5Ep zX9v2i5u2Bnm!Pt2JKt}SeM!_GR3V&zeF(Y?IPP^ZHig~iz!*W?@`yQD9=X8bW!!E3&T1{q+J_^OLNdX|;XFD# z{3LhXNCbICXM|2xy1@Xj&F8Lpe;`}=>asMvwJC@VV~$VN!` zYJ@K|FddPFGBZj=IU%j?+v8vW0@v+awWYj>h=_zlI|%;|zBa@a|6$XRmJWTXyl8zN z?5ogx160-bL|)X5u&pEeeTdaxGPqn3A^;1u3QK@@lPox zvQMMC3Y?Ybnr=&Y_~@={BT9qADH7W{IiZQGxKQO_XOU}1G>xGD0EZp4&!qh!_B)i& z`I}w~tCA43KbJdq!JIL~v%3?N?bQ0iPhc-+1cgPy2r?s;OVeMt1gr#zxR?iDdN4EV z&;|euyj&klgUgr{HW3tjTc2xcc&<|hvxdp>@i)46Ky(4+JMnS}+)q|kR@&N=03Hbd zaStv4U`k$*@eeF7FAoeTu`D`9ZEbA<5$rf$*Wc3vJpT?zEIxhu1nPm`WP|dh35h+wG-PHjZ*PHk5Z8!6n^QYexDqbAU5vNH@;oo1H zog`~}35sV>1VA$S&afd?jCQFlzz^Yle!|h>;^QOKT8@O?0WfK-+52!6-uHBssgtcO zF(oCitc^gL@O}J>tD&J$4WfbTp3I;YIDiBUWDt}T6a<8XJq0Ha@>E`Pe_B`oi{Z6Y z#p*j~qWymV05Nhs)WwPGAe{c+6ft9&CI5@=MZ-d^+Y225QzXD#1`R_2inFr(k3?oL zjVo8ktr#wu$jHe5+h?facm?@_n%XF6xZ&z*ZEV!L$b(YrK4k2$+iigJg6}u~1IM9D zEZZKZ!mfb5s)9YXs``pi(H}=b;)I~J(MGXD9x^tDE);$ggUGwx$YO6aTs%Z`(cWKt^v?t0vcs$SGNp;dl;XrB;tcDbvUi7w zZxAJPj{w*oVV?7}r9%3kAOd3_3~-R#QbIpe_*k3eK=z?XN=c#kS(=;ETpLBEcbK8Q z7)U(MU}z{X$OGRGVQ)>oHxZnKG7P$sKd>1hnHvUV18j6hU*zv69R7g^Dh~Xd7>rcd zo&dV345~2}7AP?O%f&}07t=Q~f`@~X^}te2Ru=s~kruWq(31m0LwpY&^pA{e?3()P zTAhROP6uNA<=@``5ha-30>uZU+lPmTw}{vQ_>h2yNlZ!#E(WFfT;Qibn2^^Z2HF(( zf5@d@zIXu>2{{7W!KMdIyd_{J2kif+OCQtsO)nb3-dX2ZkUBcXTMl@S;+W+YsWs^V zPo7F2fJZ@mpNv1-K6sT{D2v4H#jEA^)V()u;S~+xE9*jQSC2k|(GG+Nk*g9SJsNaU=M9cP!VP2r&>K=7ff$0!&cx9&aO|KA zT@E;CPeB*C;&B5`MA5f~nrFb|tKmQkQFx+j=t^`v$ zz!DP{*}?M&wz&UOwgHk3bkt9f69J1zM@P4}yZhhDLKqN@zQ64i6&20Q@<7sGRmEj% z&4$2VzBjiIrbkQ5iAqC3D2Pui`TvK$!_dN!V10G<=o99B5F)_i=Mok+_=p1IZE9*j zefI0{u*vr~>mp~MQM8woL4(FEdMKN{Wo+;_oE9W|@l5w%oGM_dd{$MKP9d@o5(rom zGQlFjLR24*OUqeWTqRw-dxA7om`Ejichw*Vt1o&n!cPJL+TQUK=vzavR`+A>idW)HvB zuLa?cfsv7sfr0PNbS1R7Q=-OaH)>R0abydJq%0xf3$*^S66EFQ=U`_q%y%;}p;Ekm zbaH|O3qy1?Nd8t~B~?{b73*XYgik+z{)`$Dj3l7emxHmWQ#&T$tp%`J&VTcG9eTU- z!az5$=e~F9+581{0K8jWb#;IfWoh*th?QGz;cg4qTkwc~u_yXc`YKa#7uk6lk$YLi zYr-^(`6dmN-%uMW*g9+G4XkwUA4T3df}R)=Qw-Btb{P~4VDzd`UkHxZWXz4?`Y_f( z`QgqB%>8SjW%e_$>42FA?5u}SphKU@XJdI%*OYk{uND|i0S6cZ9_0P|_YjY-CuN|Z zpcGEVK)C2!)WL>E2mn<+ov+)5?p#n~0A3?5egzj;BTS@eNe3B5baX#>wUqxC?Zg_m zW&l*kSAPQ6AaeMju<&))$@zL^=DRl5))44_z%Tqasth-zV7J#}uKz=sk^glxOUMn% z9*}FmJhXp@$H!|6<;^+}cXLhU1zvZzjCD8`Y%rX6Wzcu=aeETqp0iWJvCZ8z@&tVa zxa-1Rz+nTSCHjHZTc%QvTg7Au zCa4!ZzI3d-%Y_N8suX=PUcuA`SU&7sQ)A-*h|r*s(*5>9wPSJ7SYF;11|C3=hb*TC z`i4OG0gIIUG%IQ_v=IP16o?`K3UvpT0X`VOS}=6!0IC`|+c1|D3Wr`8fekKkz=!`Q zbhE}Mdkx!28XBJe_sEUpeKlQO-H~?!jteAIRCoxm6GJb62mkp3*ckv-tMe2#U65Y% z4Wpr=`UeDnytNiA%7FWWs+EbU1dj4Zu0+sWjWy(J&YqqyC>3{-vqNTo64V!<@7P&g z9j|xLy?r|l-ZKpCvVgHf1aTsul{{>D51k=k*LyZ(UTr_Z#=o5iDg;l^7=PqDZ>dYi z>USZC9)x}$!+1{sZ(v=jVJucmHmagTcsFx`H2?tyAw)+Q|1;|clMmN!Q?A7=u8W@1 z(lt62rs=?KA@j{AUqD9m*L#mAe@L$7MBho#@5BH}GIC#*LUgVSuUy&FS{)M;^NiT6 zd}g2wtBmhZ5YMcW zh&XIoHMo;%s}JAfih&ySvnQ)%I200SQ^X7QNMG*uieRF)dlugn-p8 z<6T8D{-m|8zBaIwd?_`q4wm~UcC+Q)VBX__xJS*xZ^J4Gy6#nci`CX{37$Kvv>wa; zg}46kHyJ^1Qi>|1*`mh8Da>DHlewMcv8KM(jvf5-dP2~b-N*UBUl)gk?74;?Iny&l z=3S1I66@@i$NOg#8TUTVNk~p`p2Zqa-J^8a>|S5xzu%7@z0zV}(YR~3%>S}njs)Qq zOa<;0@XKe4+_VD!o8FQO)SX)2ypBYP7x8@25BLI_ezj%41@D zTN|DULQu!|rpC1sKeEn76q)0gsZg%sb}DW6<6=r9V3cttQiMTs z#6lI~Cr>!^L0-Ib>QIzO{7n`s+LEb}&4@ZKnmY}TSDpkAUoZD`}7*PH;L{K zRq(hcb57>lHYiw1$v#SN_+*!J(^;fR2=|n{iifhz{Z9UmIUZQ*9EQ~Rnz}C?l$)8f z+Dt+(JrWMbS- zf~`U#p2S!mX8uu8PY$qf|LT6|{`hyRSA#6>q#T-8n0mIt-N)pjip&!*NQWYM$8DUQ zTAt$pg|zGxqT|Je)-Eqf1UBl+K4(AJxFwn`V1a^|6`b*u)Gnp6+L`K&f(}lT5&quS zj{G(`N^AKjdtz?#Hhnw}k9BPU<}0hPrNHB!Ooi*>c&hp-S(3bD6p?6kd^kpX`sg zhKGf5&rDa4)NP9>TZKaLU-b+g%QHq8_8j3WOG({6hLq*yuNvU`jZM3fYE9d+D zp5t?_@yB&tx8EN>w{ttkG2Y|#dcK~|$K$>pR(9#D6c5Bdk0gmADy86V#MDiALCKt6 zY>-~K^gKKkHg9}A-(|6y{wK%Y-O_!6zC0O2?!#s0=9E?0W6pX=EYTiuFnzztWy!9Q z{FH-&u2W>%coZ!ixC(?!^a8dg;1lmtDuG{@I% zyu8ogmOLk=AVTp%6mRwDm43&mt?_ZBfhNaGf}ndn<(nREr6iHHCMi!nZH`C~EOyJ3 z*Zz`mE6{SErU~=D%}pVxE%A@HW$#^8rqST-tFW8Zr)~SeHzz-tk6$fZb5q3SjY*@I zajH#2_j6B;K|@smo|LoHsH|Hc;RcD#&BCImrR6d;tOctCYH5WF)Dm9p44Hei zhXb$XMDuF+Bwo`gbIzN3sB$~52&)5F z*CL+lT7w2Gryr4tsd%n%M8RwRwUy1?3^96LEv@yOobw1=REKcz1na3&pO6s&qVQ5s zki0`gAR_gRjWvqC#(f%bG%NEN92w~>^Y!vvm+>E_1@gncJ0)@( z6Yp-gvXHD!!!yWsX#Em1I8&j~>5^SB2pVGcUA^r%yN?Pic6x>_ja-hPT0I9^ow?uY3?;rN1r(Dhi2=w%Z*i(UI&Quke#NbKQLjagF_C3 zhJaqAq@+O3hY+q&j6+5Z^3>&%3Igjf?vUb6O3L&4dOJjf1Kc}z?i>{V=|~zSC~DfL zNHIku39w!wUZtu^dBzbP6IxXwbpuT|IMv8Vg`z7Tg2-ECWzRl4PenM1LC5lK-i+c# zZH`Er#+U%I3>q(6yaYVK1()o>z%9Z~ATB26?(UAZOMQ0d+3tTKxbu9k8nTOZn=*_( zXp@Tm{8V`G!p*9$yDc7nD@NkQ7|2tsoSUmIfPcq7Afm6`hIrY;MKC|5EgZ#dr|-Sp zQf1pu9ZLf2us^^y$}B8C9F_2=nW_f@!H>%e+qCMHLw~)KyRdd5E?0a))4bTEe{Q{9 z{cZ;8u~5{PR3tz|7t7w()T}a)@agI9hNIrj!h#s&2Z`q2$Ywl#H*VaxdbJVwgX$@a zH0R*BjV>8pFgc&8%VkE$vH8s&BloSQMip37=U#Y>Y9OTqWn%;-&ok{UH*!oaHQZCI_mW;8(fv!wNfKNLS}* zYE0yb+b&~2!`zy7rQ2?X^zBAhKS#lC-?Bqyhvz%h?k7d%UF-iSC3rIV^TfA8FGE-5 z_ft=7n>X|+u@-MTUp0(Eh8&LNgO&*LX2F2w=c`az!`+X}G88tj^w-o}CPW?(Q7?V} zsMK6tdFS-ggrXu(H0i#N(V*D1Wv!;CFU-p`EVR}0_I^UX4RF2ftLYCH zG0;t)VpUO6x?rDHVRdhsvv9Y2@}Y=Cs|=7K<+{vLUbRPWM;70-EOdJVXf5xB*HK{& zMNhiSuI+~M!C@cO*0|Kz@O1@F`otca)Dqd{SK1mdZpCYR5@%oI5U>A8k?;OX(CjO* z>DQ_yy6MnYh{!k?eRvVMGY(?CA4Z0pxY>^i3nj$F_Ir(S`hG&!K`1Ezi$%)Src0Bg zF?dy9|1s3uV0Xg)uSb6)V`rDWq&Sn(4B9qHJ+ZpKiRzbjd>bJN>#3EW-x=FZ^Gg<&7wj zyQ6Nv+}wZ2b*~g%H%oFx#uGqB(BJ|fKx*(Xyggto_JPq4##F0J#KvS<~z(0#L9+53CUd)@@8go_yN6* zprDGrK0%;Z$o)OrU=y}Wv=H}t`1kI;VPD}oct}xk#S?FN`M{hUCpak)zHkz3%g2u{ z=wQ&eysD}~Fv?$rKab4R?CiCW5DKDMfrbyUNBrR*AgiWGvI}&8RY~jjl_wzWh<-?w^sxfklCtIy(-lx^b6=n7{aV`dPI4k?jRX6)1ezE9qK95aCr~9nwb;1m{#*Bq{KH>d zGNPhynwwqUrW}|q<5fJ8v)?5W3i^a4A(3mTzY^)$y1bo6QoFP~84Dl4!nskkuQ4%+ zR$DC0hUb7>x7u^M)%`HHKB_y&aWIq1<@3F~7b_ZFzs7f%B=U*>SfSK#(Qm%Zj}#ob zb#^RLGdT5O<)$yuRW&`{qaO!2m2>iU4K=80tg109*+haT!l*NI)1fStYn#Vb@siZW z=@+c;>+8jh#crZToQC9EiZdk2yvnT{)OzVAmE`liw}KPDo%HJ5saV*1%i|&Yv3Fv- zN+k|ZtFp8{Okvk!*t9a_N*9&A(EHRQGK}jwUxo-Z>Egv=T?Jig*N*SKdQ$v2i|Qxd z3T?xo%Xv}eeGmP}@zLloKaZ_dq)#>2%5PO{vM1_Ar81#Lwyk?Aw7BuY)+)ix8;0nf zx;gUEmse_DInZQUmt>`wV$t|Uib;X?naGVF+CrZe2E^vvg?~=3nKo;U5$D%P)2fr~ zzN#iMQCXW&2ijFudpGr6&wGsfq7RJ>?aMBCwWvRIboioAc!|%4p&7SWYx{A-+4gl+il&|pq&@qKdIoO~sv}DC;DZh|>7ckt&#pinarta7 z!5%CBjau6FStzUHyQN%d#<$7G6-o8WT6pQ?2nK4_Uq}3?>HYlK0j;6T9!BvV%RBY` z%*t~`c>>S0lv;Ms3Q8VJ|EA_QLHp_^snEb8YvF^P-BMOU${{7s5RbB&^h?io+Mkn` zXBv|{_>nUqB~qp#<@L1WYEsYI)|pZ-nTalFt3BL~Wqorv%t7DHa?>eP)6Q~31f@ip zPAa3u0fu#1Be4o&{yIl{2938heWRF2I5Et2QA_IN?O)HTcYN^MjHqrJw$aTVheuQx zJrv)REzZO_qNY!PC=8rOa}2$52mk(dsH=tIRQQM%1m zeMjF!ss>NrWi~TRKTx}CRQ8VgDiXk3>zc!|hX;>uyjC+UDS1ady{dhWRd>Q2IUmVe zv(uR@o3FLSy$IH`WUqUc%w^{yG5DQGrNf!w4Lv1*+r6C%>(8tFnAZzL!<}JA<}N&* z7oZ)Q(ERMori@@%dOKzu!<3{gawgqVUClmbDenDtj2&kQ`66?wznSXm^z|Pqe)#KZ zG-SDI`YELENe!pQ7!;#kH}AM3yCWZo!bB?rj|deBj0CjBB>_w2@Bku%6vg}XxHu%E z(UL-`J}d`p?wBrPQKUt)SdWK%z18J}Nd#V8psJZ=xFjSi^m0-?Zts(A?L+qv#AA_ub5VaLWarBi zPg1(;Bdw5$EC{!73mV?n7I75sty)@~l>=&u+G8!p9y-Fih^icQCZd(U_4PeMXi@M9 z(M1&zZl^0>qr2+K{N~;f9VT6AqE>hPU-j(vhzc5^`zn84-tx0`N*e&hh!3~al^c>8 zx_N`x)$zXu&LsKjx!jT;`@EuSrzLN_CFEudtVHaNU*fh@p@}q7p#|Lw=8G--j+f8> zJmTlRohD#bCF~YaUtD{;*yg5l3WNfaW9a3HrkPUmk5{zA|z+u@d$@DX39s$ zGes0}kH;cLiSEUAqoUXF!k|lm!J4eoMt>BbgnJ2$-OaI zt65eXSU2SPY}#0;fXK$1GXCdtHnhayy9@}(AbzOM7Oc^M-t9X*eyMB@$g?Y}mn+uU z34fIL)2*W2TWCK==cvM?I*~In7kIAZyh=E`$H(VX1l@+A=P#ipfJS*GskOBgJPkB; z$p5;D=_eSat+uIu-8JT?dZa$P2c}e(9ptx@qaOQpni`Op#b{3kJ9md6vVE;vgkXeam z`253&B9s1){4=zL>R4SVQIjNw+dV>#8h%Qi0vcq9XYkLCT~{_;5Dj8rEX{dRLP>(& z6*$@k<+-Q;u@ujV)jOvRheq=%1fT94dr#gX>(>W4IT~x<0uqF)hI2E2Hlu+6Hccqc1+ysDZQxJ*xB)lAlyF!C*84Q2daN=zwZi)ifHaP zs(Si5Iy&M;Bf(P5mx(DF(zpg`0;GR!pzcAV}8bL(=xo%Q!tBoO`zKaxo`n=HenyE_|dH!f zOU7TZx_%`6i9AnVSb6kIrmeGEiyx-ewMN4?eMOOTcys0Z{;umErBMF6d3eZRBEzv` zvEkwLq%eTw`0^lW&^oV#XB2uoMfN#N41kjRNWaRm7Wg}yqj`wIir_j4Q8uyI4HZ3jf4?o!w#U)hG5anPtfoa8A2YdVNcT?x@jRw8jMl}=0 z&hhiIciw23MAR3KLM98&12fjNimQ^_f}&aOq7R;bFd6(MW7=q__@!;Ng%=9`y-0Aw zegk&tmzB1*Hf~QX0SKXHg`p4@ZW!@(F&_ZWu7D%V_J7DcO%e_v>J}w(U|`^`Q`OE_ zxi~mD7#aDPX+gWx{IK-Mh7nPAsm!JkN`6AkBrjhHS&il6>q%7!muK;eVf#h`8cnr( zc*EE`3mS*jsr~B}S4pg~(JBd5nY{O&-w)Zq4tY0e>J_#&Ha5cb{|%G2cmtrjDmE$c z1fs?tU~6s7DdU57I~BbNeCqrBemr{eM3SW14SMMra2-t7`iWrV-`+*!hw~`R;0r-< zh2$N{gI*pUM>I6JcUx=P*o@rVbqdnRk&y3?aG^lbYJQpP5rQ){Gi70 zF!}V>U#aq36f=zZQ<+2%NSINlf=yZ9W#7YI(_i!&3v`37KZn2FIGsAMYegBjQGRV9 z-V$=U$;_8FcLzbh%8$}^k{;$^+sM9zYVn-8l+%@MOtG}<`A$WQdnYHtqGG!mr!tmNEaxu05N z)yfsC26q*j(F5CW6`8A1(K&gK#Unzix%}hxh^?UGMn8RW1D}MN8{Y#{+92Y62owVX zKe!H#7ds~x70p08j;wgK3((5oYZ9VBly7LSRbg+raz#g9pC%BS!I?8>Krs?o6cE8; zcnVA#OBj(OCN3TTk`K8Fbz^!D)M6CUwSp#8MN}s?vaNCY;mR04T*2Zezd4A(cAuD6 zzK7oyq%$!UzB5laO_BS}zWz`Q@Wsav=3xpGJRR$1W?7-7>C<`KRh z?ti?WHXP10w{1e8ucK_-ytG`9HF~LoSd96Z}?#6 zWG~j&)1wH4O7G`RLZ>IV`f)^35(41hM(}6n;CSEFRR`EC)myYk-XUJW;0vN{Pvw_P zJU_k1LXMnXwp#WP1NO<&X`f5Wc-Z?EIuhdD9LY~3=xMv_>*@dn5Xfl$#<_dY1yPW2 z!N}qG7xn`OeE|Ic=-AG_)gA{JCVk|YwzB)QT%r`3EDYChV#{mSAnJ-AahkS ztZKDEEJ$Add&!HIHR;mH*UyWu;teun6KmVyU_ls+NTiP5sxn)iJWt;2@2aQWSd=R^ zmK?q!9mCoHZO2l3#p2+=fU?fmNTuKO#EV*T)lZ>b1+k>@eWxl+>S2Uqhf!PVieE z@sb3lgm-dk#Yq(|ls$^a?o@KW#>c$;_N-DZl~`)%YYi$(^$gn$*um9Vb(kl11mAl( zaytydN*wmuIyzXuUN-bZBD*~BxiYQ8hp(6(FgSi(Eh{eN{{0QhY8?;sE?9%mlA-iF zdRHZUVs;i<5AEXjx?hTSd&fyubsp|sggXqO)y5^B*-xMPz;OVinUb2$`b-Xk?Fxx^ z+DtF7%&K}DcdXpvyKeWkVDZpEEh)uSyeFNHMJPCg9ks1zmCr;|l=k)HFq;5_z0>E# zR+{5W%E+cxX1#ozQdO8}75K-aFGb|8V<_f6W#Juy+;-ak^96y6wkU6&}oQ&-hd)T}7b_C|Q zy1SR2UD)3VH^S6momuv!7cWkm%`GQh}&7oted?-!xq;pY!Jaq}M$ z_q8R>MTfgrld}FE-*iW0eUvpGfa$W;o1${MOD>g`awMB!Y3Xtum)G@!t4_7BTnfq3 z*1>dAO_vh=9P^U5QBisY1T4{fDQ7mBSPKhBUk z@E#}z&|nDkGLo=Y{@E&s?AoPO?HMA4X<)yDh+MH(^Vh}utc=kRYFDj0D*Fvh4 z)T&f&$y(~F5{hS`>mL00`Ewhh+KJR8=qDEITE_d@7}^WmN_KhYSKmP(6@iwn#a zE@CyDva))NBM)KO$S9wmZa1@#`a?)XGFa{a694KO_MjcL5n26r1|mNoMYVfJIny5; z#Kh!k-t!hCktZ(HOtHk&>e3|` zuf=2K9IGmOe#4|hOqZ^%E~GpF5RPRTyoE+Qk1JXly9E*;JP_K1Gy8WG2xbcEuQ+E8 zS$%lBgCrXV$3YbpLc|H53}9pgQ}|5`gsAcxmieIE_dxv$ss)Fs+9_`CB*^HgNYE7i zg4352SP)AxH`kmL*Zm#mP29WO|H{Ehhcy(OU(LN0K^^>w#n)A5SW5v;wmBYL#mgTq zd{ZOeQZ=sx@xk*S6dEqhwnpe&+Y|k*7&)1Zv>EPMWygh247Q*G?k@4VW(*Km4(Ftw z_Qf8Itg5UeGTqT$w!eGl=Hjw??b=wk)0ybrpy`49ZsUdxu{UnaLVy^20$m;h%*$xx zgT0KlV5E&0=0BjcUMKs52RBw+d=irkA^n1vRUfww0J>^E>H&w-4YiDMFBUA%>fa+t!Reu$d^%>}R%I;?suqKF~icvrPGJ?2Y_5;3`7E<4x ztdPP)j#_@*3wJxu9&C4O+zPIXa+}iqC&IkWa>xw{vx5>CikdT2p3uU>IZ7yMwsqxH zOCV$fRd#1r7b2a^&;a5Sf&f4@gd+z(b`ZJEA5uL%hToh(@H3Q3pn7Lo}EC|Y-^+2zwLtZ zD48l|pQ`QI$%h6hL6v0=znTIT^TAbCU=T7c1~%a?P^zc_OFX{r`ImD3UT@>?VqOXo zQ&%_Wa>ipJ<)L4QU2fiFh5U=?Ro|DDmA&+OJ~a7v7ZvMIrf5wgdDm_V zl3>Z3aEl+t^LImIJ!p<`HXS{W#S>(B4%_#@`GWMGH{!H+ZoTSSov6xhJ0`oEwzJ|A z3YY=2z}5Ok4`fp(#BVM4+%)$tG-!lenZU=fs}Ofa^K0dtt!hj=gIKVDh^o0d1b+MgBD`3lXRHp0oVtyl)PA8j~N%`^K6xkw_L%gY? z`{GOnECtHTF1AFhZ}+71y0mYOh$lS~grGUkyel6nrx`E3S3AkAaOpjEfxJ_*aCLT3 zy>=v@#cfhN0_BD%-KjIXZ_le;>Q$q-6}zEO>2^YVsOIg7hd1fu(amJNUg_moM3<1- z)!fKJ{GB>QS^-MCbI4bq(Ry^g_36ux7zd>(^Ty)_oI7kjDnBo#*3}H18Y+HQkLF`* zt{*i%sIJpG)5xXA^G=WVuop}1+gJ0Un5|yn*W4UxvcJYCX`c~7v-m&J2ic_$d0CVM z8#y=|sB^bQxQ?CcJ+ye62cIBQvEXaY=P|{eF6+Lvr1r;j(P^E1eR8QL9w73wjUL}m zOGBYG^|ieTK-9Z;gd5u1KM3n?WO#VDv(Rq@)Z?*IHv1OsZ7z4pOeG$PT2bR(ZoA9K zE0x^Vy-8CKdM~g)-zO#xX=qe{#s%MWm0OMrI>_z?Mb-1bp{FKBxbD+_qj}YZemMDb z(76&mXrK{sSnl`J6*>9n*b%veBLUktw6RPiMMXu{;N$pR(DWELEh8=c4^VKj^+B9; zCQ|WczOP1`&)1V;v|96yiD)!Zu=1~i*MY8C*6yCezX-M z(_mQTr_<5uE|=m>$wiPLV{yA5ITNHQ{=Cv;J(?Fk#F`ddA4jc$z8g8(a1sIVX7!D9Hb^X>YLMoe(Akws`_28hAXL@+oU~TlX z2(0z1^X@23WYjhxAXd~_Ul~Gkf`&h!avRPvI?jEM5N?kh48zo6YVh&#f#@E2UsR+& z&NtK)HXNN*vr2Htbar&Oe{E~%?Og=8L~_Q_^xF*%je?n(8R&HHnQMmI^3yar0=nS9 z3TU~?pooQue_%z7E7`oN&;#uFb2urdjGZX>YL? zqJXGOa!($>R5grK0U50pgBti00mm3WaTS1|skPMu7M3OddSBp>3 z1avQaJGW`I+ijVVIKGRZ(1mw8$OuxUIEaO;P$JHdRd`{=>5W^rI-6ny*vmi#KZ}Mv zVBF8Np@7W+tV6Z-;#rSld*8;d=ZMCZcZ;F}(Nv zsLJ1Rmt_pSO_a1F-%c^lsWTQ|IXGERL#KAT);i}DSVB_i5O?Hgib;Tk%{2~sDO>&R z+1&WBQLA(;uba1jxiGPN&bGmQJRFmq;15a(0EiGU{&M4ovvb*TakTNzkW9RjAeKf) zv%AaCA0oy$?VK;d0GwT1v<|(BkY2e0Iy#b)ByUMj?70I7e1kiKcR8@Lq^3So?N^7g z>Z7X{z&{5W<`MK&P+D3VR5B$czDSn9)_mv?up=Ezw)yyVn>Q~Y(yb)vFZgz-M(%Qy3izpNh-(Ru0N(9EzUjTSy=fA%|d$XbvePB;d4~jon`XRYm zLHf-J;SfPDhiVbOZ^iogwo^HZ#XaKUMwlT7>wp2m>_$gN zb6%aUn{4XsHSOmDf*lqf4#x!^VjOia79$MkxT&eEb6+KJhM4QuiELUFrs(B=kMKeE z1;BS=K(e&AtF?7{S(!gl32|9)&G2P35%Y*SK-l%KjD2|dQUE<>aq$_5)vyjkIw4L% z=ByW*4uDdIMn))n75Euz_obgX6{$J^$ba|^Z(~meWHfK@zCx#?%=Z>}GWK!D-qJ0M zAyC#q1C+YyeEwrVCXVlmvdb5CZrhfhm)C_$jE(_kJo59J5~PF}L(mOjcr)fznHd|C zzka=)m}oi_jk5+@LGxc*0gJw=X`^TuW{@RetX0Jl&dYyy<}{xH=>*#@xu;|ntuO^r z9p2W}Wumh{w~T}G1ncMgCRq%q_gnZSVT0TH_q^3Z>%RQWzcYuHn<6mmLg?OD$goZU zodNpFD}`v4ACL}F^g#lM{atJ=Z}|K|#XtQ(IpyWDHW~kYO%Fjdkun{WasSB6rcwI1BTHc0O)S*bxH(Oh#z4} zs=7Iga=tTRCs+~ql2?Wpl!5cv*`xmwW^#DOL>`YXgY8Sy8iFVTmz8ZFDi?r}0=!Q6 z`=BDNeg$vo#HvwXTyYqLgRTub)d-nfYTuc++{D%j<*nm&ahm>80fJqghp;Gy^gsRz6m|a@h$OBN1M!t{w()7MeCoPTf*aet%_G?DH@uRib>#%ZsweFMjf5 z0SZx6cgb(_fYV9bGto9?qK207)TG1@_3b&$+U-RYFF_ z9~K+P6%m|t3+BO3a=FXbSw#Q6iKHHw7Uw}-;&kM_$0~44yIPlR`G10R<&|_Si8v(y zPgpN38#X}B2TnH)6#{u;^S?|ft}VZVFw0`_&Pb;QI30ZPqQ|%11k-6)P#~d&T$q^c zi46k10Uk)a0wkZRC7w42E)Z`Y)D=M;Ui<-79a6#YUPQ+W%^qM&e*}S`_kBmr@8u0{ z5za_#2gLJ$%>clp5{K@wV~w}OU$wQ(;L*fw1)_9+jRo9JL>2)~3Bx>#T8NT~z;&n$ zrl#ECY%VS?4tD1t7ThY{n7e1L`g(Xw6RAhvmKJyN@tt=JyL|Z!-WQZS#hpiw-asD* zvJ6}te>+r)S?RV=QtVp~0X?ed3V!m6&>GhK7C^k1>A{Af-W~!0T{6=-qKv1=F%pm2U-o?^&ExC1iK~i^Rp+VU zcx6J%;#Y|0%+31{+yX-BgoA?&gqr{en){&cBbhrmG?d2xZqn9nhEKQFH5ma%Xz4;O zUq-&e464D`4GlJP`0t8Jfc`j6;h7xa!jM@$t985f6R-f@N?xTSqw1#VIOi;GGDRgG zYZTu}@LzFrJDol~)2eT_36rgM*EBRxDfDx$1}60tVvw{uT?gVP4VS$Fq)Tv|techFN!{rxActcF0?K^a|S*FJ_IW-oX5m4N_e zkUC2Wgo(k_G_jz}A9WdKEzi!(IGX2NcWwW2ATiOci_l7<_$SXljJ-cN%RpT6aOH8~ zXytUsm+l&24Pxpe3L$T}lS$Se%4JBH7#{2Y0R5JaUIKpG3P>b241D5jvx?rv#vS|j z``~fgxWibp_EY3!^T?%_8WS&@l`{_B2bCLHoh zP-&9hK;(c%4EKOd%h!mA{n^09^mv|&XbI9Ig}I8O?`@3AZ0!6oF?g6X+?LHjVrFK3 z#KF8_!-~L+44bm6mX40H7)nYgMo?5B`WIm%v;Yf%4FE?ShoQj_`uGG$<`L(Jhv4MN z6#V<2j`CewLMEXK1uTp?%1|mu@7lHAc2WfcZU@K498aI#gVl^{KTb)+o&5JqT0zN| z3Nsb0u+ z?8DK<=*5LVbu{VsQ&TOitiTdKDJ}H_V*x`e0t8wSn0DIUejYq5Py*D4YZ3djeBFsv z4U_u7XLuL8F?8bzxxYUuCPvTLxYYdFdHl85u@4|?v0=b%QCL(|?)>q*DnCLf{=Klo zsqhYmAW(%!)$8%`1VRQJmI#7JU;>#)4J;U9T!)dUL^azLydV@JCD3aZQgZTx#21{T z*x|tm@dbb=(c@=~q;bH41e$;o=|On-5qLf4WV!acV}fl^_rZ|xm%cli%GS`+FU-7m zN|mu}=JIRI2bh%<6N9)bf2d`E7h$n4;L8?ub_v2c3_<&i>z~ zbw4>jSt1ex0S&KS4Z$npUV`96To9zCkw-_lWqekW{+N+~VdX+11f4uEP?czd=yM_r z8mZP2dtIVBph!XfFej3s{!T7&f|H?sOjqK4<9@@1OXCx zZg_OG2R_g|nvn3p0MVDVXX@meFEo9G`NYz)6(hkA6a!F9ly0)qDhU-!`Pl`-u109{ z{_ysHA&Yi4;RU7Khs`Z54`>`BdiC@|n78-t-HR=D5pGpPf8%fAaDt=;Kf%MLHjQrJ z1x6|V0vd@E6h{ZnBqM+|h$^>weUo>H_y3aXA+|Kc$lobyE>$KQ?nlE2Rp)=@3`Y1! zRc>8pVQ+7Ye$UXbuJ=#=r{l?PYHI3%^S_-Xgb4%HJo}w@+$>~iwl5EI$}!K9K;T5a zZJu*>O__aKUNSL0{u8@3sSmpL@P1wQ+LyGcwe6M4LobGkQ7x;oXlEr_Pg7pLua>KAc#d$FdXzG{GY7vG0I4>LPeX z*nP3ezX0??TaZGJbM61B-8*VR;$91#uSo0Z6G#lOuy7yimh@)7y_`JQyb7TW*DRJO zNw@fEN&hohU@q8Cb_qKuib*OeD)=q<^Vx}f5sYQkIah0pc8QQsL6xFg>aVPoiOI^fVy9YA#z@Xa zE;>hb+u*c zN_8^Xe*g7tqs};T2_Xt9Lh${h%0KKbj8Zx|iP4Sf@%wi1@R-BTF{J>O9Ybn1s`YVU ziYoTnVgKzII1n0|B6H&(mkwLt-@31eNl_2kV0XCzix#-hhu3XgKB|t^9g20bVfS#| zN(@&;oeG&DW@_Rd2?_~aQBI-f*u%Q~!w<7W3I=h30bQZU+ymMU0fBigSdSp&ZedUp zItEOoMpb)&?=7C)TH>A?A3y#CM+=BFa4Uq_1)L_JRQDxHt{!+4=lE9wRv&M{$i}T1lw15k2!D-tY}pd|!$)}A zHls2h59~@%EaC&|96KhM`-05XynwGkOn7T#TwbA9QmSe*q$4L{H#=yljs+FiApr=~ z-4+Q6J?{k{q5rnFgy*`Q1h`VMv?!HO)meu?1?0k>e;q`;2m>+-Xi}$}-{`WnM4=@z z%ieA~J2eGq_V#Vt-b0-Q;U$_1x7p?XAU&kOZftFV7J5ju79=Fev0>zRcaIhuFgz3^ zPkm<{@v)HtA^&9~QQfX8q`J9P=Ti0InC1RlF*}h_#}dHoT$gV)mxb5d(l z2t;@lZ|nJJn8}G>*QQ`C{{3tBRuHar;`LOi%=>;fToJUDl?3dDKgtq#j?mWN=UYjz zSrfk$q)VbEU`+fxgG49`iJ$-P7hJv~|EpX>tu-%2)!n=(B$#DGvPrTbnQN02^e#2= zoP6>B_KoceQBL_hrgXQGYR&TE&^!!z{Pnz~V;p}iu$DOUOuGMHmk`UU?gyEf#85EA zrC>X_Y<4>-3G&Jh*El8f;7{iEpLz@E0JYa^8>WpLhtLF_IwcOp=r^R`fuD<~Mw2u( zGsA_kRrqrd-ZUG`-kXjZ=VW1K28P;>;WhzFi_oMI#_1xb4{(fcFK6o1h^`tMzI#pf z%W=XYUEqSf4yW;6hc4yE@iM-#MG%YRG>>^dn1Mb6Z{>(B6+1oHD#QvA4&o9DS@ee~nL$ z2{R-7<2l;aUAuBImK(x)bg3|OhlPYBI{;b2A4EXoPG(vHoyUX~I7<+a{+60ZGUh&y znjRCKImAz^qjY=MUDBp7$_^{;87m~vW<$eTQO0d`~TjNE=1`bx@BB&ZFYIZeK z&LRis3#w&&ts6gBO^ThLH8)QnM)Qu5X}KRJ91vRe2`eNS({THVI`v9-@t}Q0dxz@6 zy&m-?VzDq22%m>3+2R=AQMf-Z=`KW--+D@JlvzS`gvJk;cdF4Bzc>r&gJ|?H z^c?Y`IUSd9*0Et|bZY7#K(*m&99DBbe@e_R7EyuD0l#5X?9A&+(3AAc-?!LVvQGr1 zRJWynJGh?OJN(W$vn%xLOukDx@Qd&YJB(zAK^M%5#Sw~ zF?w%Bty6X_vv-LK5ewvXl)zy?2dUk2%__UU+vV<3+~{{SCPiRns)&rUkjahH=-yBZ zU<{EaiX&7EL})#-F~F>%tBs=mujtB|Y|JY{V>AWmW5NocEzn**K0YYw@NeL0lB1FK zc}bgH0Bpf*mgG0Fx<`BH9siuTCd{l0uig9loUY7mnf+tsL_*<%&o0!%JsniQ1pQr9?(D*5SBbTGO7Lh(HM_) z=BI&qKH@9a717<@jXVo{N7s}RK$R5F$VSEqTJxWfk05h72B)=s9*A@nQ0l6YG zdkOd|`|McZ26Rr8fpC^%2b06u4~XyeYcjf+l)Eeb@fC7UyrAA6@b>-tlGS4y`Kl^5 z#IO)gM^&7lu-<5883v2;@bsYpfTbAfTcB)oFVt^|2}nr9>_!YF0_&XX1v1(1lEW=lHz+=A+Mv+{X`Y)JC=&Lk)X zRplJ8ytv~>{GQzVs}Jc(IbRD6g$%A4%kp42JH`znH89uX>oFAiFakvJw*b;4@o+on z5?PM461Sil#AfkV*yF2DxjD9d?=V5kZW&>ihBg{Zg|&?hs!Mv3YRAD8CH1H28(sG} z1LShQf*S@$Bfcn7LMd@!_y5r9jFOm{TQ9NE#_YW#!^_5E(1fZh4o&9*1= zDmIL-*3fURN3DVwCI)w~du8?zHR{P5J6DQ*Ga_>PV>6W4p6bx$${M3=O$23$_x$lJsZCpJ) zH_%=LtVg!t=KtaQYii3;x2%njRDs(wmS_Xq_#YOuR3Jp^H+z!utHDcueKvS{Qv7_A^H9u zG^@aJp>lS^K@ArV8V)w1gVDV5ZqgqhT|;X$!cfOq+ec*jAa>5#`8#Ye79fpIR$azLFE1{>Y0Lt&V&c=Qa1Jk& zJ1EB9cXaHNl5!Kg06C$rw>M;F(5FFIG>n6JcWz=nQ}bDzE1sU7B%qHJuC1Xg+kjmh z+VO7w7GSkFdr6Q5Lp?n1J4IM_3=3+WJxd1g3oN@EkR<3uj*pQbd18Wy z#x3I>fUKiHAuziE%_G26Tpua$7(lLxKv@zij$#d-bu6aYl{G1+aIzII9_(||Ns1Q$s%uMT4aBSkn*Kx$%Utl}PR ztV~&#{*}IK<_u7za$z$7Rwy|;jLQ!q9)J3BXeg5P<^bIT?tX;zI4I`{wl!|KCyklP z30NTX7tmh8S9%VaIY5Gt(GGO~0?z8lo~%cYzC*4dAubM$F5ZlbM2HUuGVytEEj;UE zMF2KHYzBJwC%^`djK}e(!a+pgS&82Rwh<7s-2C?okWb>Zfdt~nbAyAq>jZlDPs^X7 z6<>QC7Yj9LA&L$6Pxu5BJV2Uz@}Ne8fM6gGY)BbZ+aF>y!^d?^s9fvF5qc8nfsqX}-8vz!QYNi!ZsY46H!y&P z-TT)yd^(2vbv!{4TYq9)1pw^Kj0{Ogzwv-!BIMf#sylw9C7s>cbQYxpH9h+j0yD+d z#i**OWlRRcqzmkj*%yhsupIf!&164(=m<*2mBw+>u3&f&6(O|RZanK@{ldWe0#}CS|)U-6rKOYBo4iVWB z963Tq;s!=X%ODN7C64hpxN(}A3}tHAskRI!#>N&w5(+pi4T}$L;xJ*!1HNq!!Uz$_ ze38~*C_#XOgrN)sB?CH~f+TrNMI{*aLoC&@(o*YnBdXG62hrd`;z?kS$oxjdaW9O9 zg|Of(Lpiqm0s{WP!|_4KzkkPR2++U(IU#qjT^`puGE&=1eE#t>(lj)eK7YXu$;(DO zAIU{uvElVX+<`g@i>4NsAqLTsik`kc^wY##aLf&W;s7dB z>_LQjS argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Draw a world map of ERA5 raw-verified cells that are present at " + "one timestamp and missing at another." + ) + ) + parser.add_argument("--raw-db", type=Path, default=DEFAULT_RAW_DB) + parser.add_argument("--indexed-db", type=Path, default=DEFAULT_INDEXED_DB) + parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + parser.add_argument("--present-timestamp", type=int, default=DEFAULT_PRESENT_TIMESTAMP) + parser.add_argument("--missing-timestamp", type=int, default=DEFAULT_MISSING_TIMESTAMP) + parser.add_argument( + "--exclude-igbp", + nargs="*", + default=DEFAULT_EXCLUDED_IGBP, + help="IGBP classes excluded from the plotted coordinate set.", + ) + parser.add_argument("--width", type=int, default=1800) + parser.add_argument("--height", type=int, default=900) + parser.add_argument( + "--raw-neighborhood-samples", + type=int, + default=25, + help="Number of hole cells to verify by raw primary-key neighborhood.", + ) + parser.add_argument( + "--verify-available-row-ids", + action="store_true", + help=( + "Also verify every blue context row id against the raw DB. " + "Default skips this because it causes many random reads." + ), + ) + parser.add_argument( + "--skip-raw-verification", + action="store_true", + help="Write the PNG/report/cache without raw ec_data row-id verification.", + ) + parser.add_argument( + "--verify-cache", + type=Path, + default=None, + help="Only verify a previously written *_holes.csv cache against the raw DB.", + ) + parser.add_argument( + "--verify-cache-start", + type=int, + default=0, + help="Start offset for --verify-cache chunking.", + ) + parser.add_argument( + "--verify-cache-limit", + type=int, + default=None, + help="Maximum number of cached rows to verify in this pass.", + ) + return parser.parse_args() + + +def connect_readonly(path: Path) -> sqlite3.Connection: + path = path.expanduser().resolve() + uri = f"file:{quote(str(path), safe='/')}?mode=ro&immutable=1" + conn = sqlite3.connect(uri, uri=True, timeout=60) + conn.execute("PRAGMA query_only = ON") + conn.execute("PRAGMA temp_store = MEMORY") + conn.execute("PRAGMA cache_size = -131072") + return conn + + +def attach_raw_coord_db(conn: sqlite3.Connection, raw_db: Path, schema: str = "rawdb") -> None: + uri = f"file:{quote(str(raw_db.expanduser().resolve()), safe='/')}?mode=ro&immutable=1" + escaped_uri = uri.replace("'", "''") + escaped_schema = schema.replace('"', '""') + conn.execute(f"ATTACH DATABASE '{escaped_uri}' AS \"{escaped_schema}\"") + + +def timestamp_label(timestamp: int) -> str: + return datetime.strptime(str(timestamp), "%Y%m%d%H%M%S").strftime("%Y-%m-%d %H:%M") + + +def add_hours(timestamp: int, hours: int) -> int: + dt = datetime.strptime(str(timestamp), "%Y%m%d%H%M%S") + timedelta(hours=hours) + return int(dt.strftime("%Y%m%d%H%M%S")) + + +def output_stem(present_timestamp: int, missing_timestamp: int) -> str: + return f"raw_era5_holes_{missing_timestamp}_vs_{present_timestamp}" + + +def load_coords(raw_conn: sqlite3.Connection, excluded_igbp: set[str]) -> list[Coord]: + rows = raw_conn.execute( + """ + SELECT coord_id, lat, lon, igbp + FROM coord_data + WHERE coord_id IS NOT NULL + AND lat IS NOT NULL + AND lon IS NOT NULL + ORDER BY coord_id; + """ + ).fetchall() + coords = [] + for coord_id, lat, lon, igbp in rows: + igbp_text = "" if igbp is None else str(igbp).upper() + if igbp_text in excluded_igbp: + continue + coords.append(Coord(int(coord_id), float(lat), float(lon), igbp_text)) + return coords + + +def classify_raw_coords_with_indexed_db( + indexed_conn: sqlite3.Connection, + raw_db: Path, + excluded_igbp: tuple[str, ...], + present_timestamp: int, + missing_timestamp: int, +) -> tuple[int, list[ClassifiedCoord], list[ClassifiedCoord]]: + attach_raw_coord_db(indexed_conn, raw_db) + excluded_sql = "" + params: list[object] = [present_timestamp, missing_timestamp] + if excluded_igbp: + placeholders = ",".join("?" for _ in excluded_igbp) + excluded_sql = f"AND UPPER(COALESCE(c.igbp, '')) NOT IN ({placeholders})" + params.extend(excluded_igbp) + + rows = indexed_conn.execute( + f""" + SELECT + c.coord_id, + c.lat, + c.lon, + COALESCE(c.igbp, '') AS igbp, + ( + SELECT MIN(ec_present.id) + FROM main.ec_data ec_present + WHERE ec_present.coord_id = c.coord_id + AND ec_present.timestamp = ? + ) AS present_id, + ( + SELECT MIN(ec_missing.id) + FROM main.ec_data ec_missing + WHERE ec_missing.coord_id = c.coord_id + AND ec_missing.timestamp = ? + ) AS missing_id + FROM rawdb.coord_data c + WHERE c.coord_id IS NOT NULL + AND c.lat IS NOT NULL + AND c.lon IS NOT NULL + {excluded_sql} + ORDER BY c.coord_id; + """, + params, + ).fetchall() + + holes: list[ClassifiedCoord] = [] + available_at_missing: list[ClassifiedCoord] = [] + for coord_id, lat, lon, igbp, present_id, missing_id in rows: + coord = Coord(int(coord_id), float(lat), float(lon), str(igbp).upper()) + if missing_id is not None: + available_at_missing.append( + ClassifiedCoord(coord=coord, row_id=int(missing_id), row_timestamp=missing_timestamp) + ) + elif present_id is not None: + holes.append( + ClassifiedCoord(coord=coord, row_id=int(present_id), row_timestamp=present_timestamp) + ) + + return len(rows), available_at_missing, holes + + +def classify_coords( + indexed_conn: sqlite3.Connection, + coords: list[Coord], + present_timestamp: int, + missing_timestamp: int, +) -> tuple[list[ClassifiedCoord], list[ClassifiedCoord]]: + holes: list[ClassifiedCoord] = [] + available_at_missing: list[ClassifiedCoord] = [] + + query = """ + SELECT timestamp, id + FROM ec_data + WHERE coord_id = ? + AND timestamp IN (?, ?) + ORDER BY timestamp, id; + """ + for idx, coord in enumerate(coords, start=1): + rows = indexed_conn.execute( + query, + (coord.coord_id, present_timestamp, missing_timestamp), + ).fetchall() + present_ids = [int(row_id) for ts, row_id in rows if int(ts) == present_timestamp] + missing_ids = [int(row_id) for ts, row_id in rows if int(ts) == missing_timestamp] + if missing_ids: + available_at_missing.append( + ClassifiedCoord(coord=coord, row_id=missing_ids[0], row_timestamp=missing_timestamp) + ) + elif present_ids: + holes.append( + ClassifiedCoord(coord=coord, row_id=present_ids[0], row_timestamp=present_timestamp) + ) + + if idx % 25_000 == 0: + print( + f"classified {idx:,}/{len(coords):,} coords: " + f"available={len(available_at_missing):,} holes={len(holes):,}", + flush=True, + ) + + return available_at_missing, holes + + +def iter_rowid_ranges(records: list[ClassifiedCoord], max_span: int): + sorted_records = sorted(records, key=lambda record: record.row_id) + chunk: list[ClassifiedCoord] = [] + chunk_min = None + for record in sorted_records: + if chunk and chunk_min is not None and record.row_id - chunk_min > max_span: + yield chunk + chunk = [] + chunk_min = None + if not chunk: + chunk_min = record.row_id + chunk.append(record) + if chunk: + yield chunk + + +def verify_raw_row_ids( + raw_conn: sqlite3.Connection, + records: list[ClassifiedCoord], + label: str, +) -> int: + expected = { + record.row_id: (record.coord.coord_id, record.row_timestamp) + for record in records + } + verified_ids: set[int] = set() + for chunk in iter_rowid_ranges(records, RAW_VERIFY_MAX_ID_SPAN): + chunk_ids = {record.row_id for record in chunk} + row_id_min = min(chunk_ids) + row_id_max = max(chunk_ids) + raw_rows = raw_conn.execute( + """ + SELECT id, coord_id, timestamp + FROM ec_data + WHERE id BETWEEN ? AND ?; + """, + (row_id_min, row_id_max), + ).fetchall() + for row_id, coord_id, timestamp in raw_rows: + row_id = int(row_id) + if row_id not in chunk_ids: + continue + expected_coord_id, expected_timestamp = expected[int(row_id)] + if int(coord_id) != expected_coord_id or int(timestamp) != expected_timestamp: + raise RuntimeError( + f"Raw DB mismatch for {label} row id {row_id}: " + f"expected coord_id={expected_coord_id}, timestamp={expected_timestamp}; " + f"got coord_id={coord_id}, timestamp={timestamp}" + ) + verified_ids.add(row_id) + verified = len(verified_ids) + if verified != len(records): + raise RuntimeError( + f"Raw DB verification for {label} found {verified:,}/{len(records):,} row ids." + ) + return verified + + +def verify_raw_hole_neighborhoods( + raw_conn: sqlite3.Connection, + _indexed_conn: sqlite3.Connection, + holes: list[ClassifiedCoord], + missing_timestamp: int, + samples: int, +) -> tuple[int, list[str]]: + if samples <= 0 or not holes: + return 0, [] + + checked = 0 + examples: list[str] = [] + for record in holes[:samples]: + raw_rows = raw_conn.execute( + """ + SELECT id, coord_id, timestamp + FROM ec_data + WHERE id BETWEEN ? AND ? + ORDER BY id; + """, + (record.row_id - 12, record.row_id + 12), + ).fetchall() + same_coord_timestamps = [ + int(timestamp) + for _row_id, coord_id, timestamp in raw_rows + if int(coord_id) == record.coord.coord_id + ] + if missing_timestamp in same_coord_timestamps: + raise RuntimeError( + f"Raw DB neighborhood contains missing timestamp {missing_timestamp} " + f"for coord_id={record.coord.coord_id}." + ) + checked += 1 + if len(examples) < 5: + examples.append( + f"coord_id={record.coord.coord_id} lat={record.coord.lat:.2f} " + f"lon={record.coord.lon:.2f} raw timestamps={same_coord_timestamps[:12]}" + ) + return checked, examples + + +def lonlat_to_pixel(lon: float, lat: float, frame: tuple[int, int, int, int]) -> tuple[int, int]: + left, top, right, bottom = frame + x = left + (lon + 180.0) / 360.0 * (right - left) + y = top + (90.0 - lat) / 180.0 * (bottom - top) + return int(round(x)), int(round(y)) + + +def draw_points( + draw: ImageDraw.ImageDraw, + records: list[ClassifiedCoord], + frame: tuple[int, int, int, int], + color: tuple[int, int, int], + radius: int, +) -> None: + for record in records: + x, y = lonlat_to_pixel(record.coord.lon, record.coord.lat, frame) + if radius <= 0: + draw.point((x, y), fill=color) + else: + draw.rectangle((x - radius, y - radius, x + radius, y + radius), fill=color) + + +def draw_region_box( + draw: ImageDraw.ImageDraw, + frame: tuple[int, int, int, int], + bounds: tuple[float, float, float, float], + label: str, + font: ImageFont.ImageFont, +) -> None: + lat_min, lat_max, lon_min, lon_max = bounds + x0, y0 = lonlat_to_pixel(lon_min, lat_max, frame) + x1, y1 = lonlat_to_pixel(lon_max, lat_min, frame) + draw.rectangle((x0, y0, x1, y1), outline=(35, 35, 35), width=2) + draw.text((x0 + 5, max(y0 - 16, frame[1] + 2)), label, fill=(35, 35, 35), font=font) + + +def render_map( + output_png: Path, + available_at_missing: list[ClassifiedCoord], + holes: list[ClassifiedCoord], + present_timestamp: int, + missing_timestamp: int, + width: int, + height: int, +) -> None: + image = Image.new("RGB", (width, height), "white") + draw = ImageDraw.Draw(image) + font = ImageFont.load_default() + title_font = ImageFont.load_default() + + frame = (80, 70, width - 45, height - 95) + left, top, right, bottom = frame + + draw.rectangle(frame, outline=(60, 60, 60), width=2) + for lon in range(-180, 181, 30): + x, _ = lonlat_to_pixel(lon, 0, frame) + draw.line((x, top, x, bottom), fill=(225, 225, 225), width=1) + draw.text((x - 13, bottom + 8), str(lon), fill=(70, 70, 70), font=font) + for lat in range(-60, 91, 30): + _, y = lonlat_to_pixel(0, lat, frame) + draw.line((left, y, right, y), fill=(225, 225, 225), width=1) + draw.text((left - 42, y - 6), str(lat), fill=(70, 70, 70), font=font) + + draw_points(draw, available_at_missing, frame, color=(70, 112, 255), radius=0) + draw_points(draw, holes, frame, color=(230, 35, 35), radius=1) + + region_boxes = { + "India": (5.0, 35.0, 65.0, 95.0), + "Australia": (-40.0, -15.0, 125.0, 145.0), + "East Canada": (45.0, 65.0, -80.0, -50.0), + } + for label, bounds in region_boxes.items(): + draw_region_box(draw, frame, bounds, label, font) + + title = ( + "Raw ERA5 DB hole check: cells present at " + f"{timestamp_label(present_timestamp)} but missing at {timestamp_label(missing_timestamp)}" + ) + draw.text((left, 25), title, fill=(20, 20, 20), font=title_font) + + legend_y = bottom + 38 + draw.rectangle((left, legend_y, left + 16, legend_y + 16), fill=(70, 112, 255)) + draw.text( + (left + 24, legend_y + 1), + f"present at missing timestamp: {len(available_at_missing):,}", + fill=(20, 20, 20), + font=font, + ) + draw.rectangle((left + 330, legend_y, left + 346, legend_y + 16), fill=(230, 35, 35)) + draw.text( + (left + 354, legend_y + 1), + f"holes: present previous hour, absent next hour: {len(holes):,}", + fill=(20, 20, 20), + font=font, + ) + + image.save(output_png) + + +def write_hole_cache(output_csv: Path, holes: list[ClassifiedCoord]) -> None: + with output_csv.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(("coord_id", "lat", "lon", "igbp", "row_id", "row_timestamp")) + for record in holes: + writer.writerow( + ( + record.coord.coord_id, + f"{record.coord.lat:.8f}", + f"{record.coord.lon:.8f}", + record.coord.igbp, + record.row_id, + record.row_timestamp, + ) + ) + + +def load_hole_cache(input_csv: Path) -> list[ClassifiedCoord]: + holes: list[ClassifiedCoord] = [] + with input_csv.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + for row in reader: + coord = Coord( + coord_id=int(row["coord_id"]), + lat=float(row["lat"]), + lon=float(row["lon"]), + igbp=row["igbp"], + ) + holes.append( + ClassifiedCoord( + coord=coord, + row_id=int(row["row_id"]), + row_timestamp=int(row["row_timestamp"]), + ) + ) + return holes + + +def write_report( + output_report: Path, + raw_db: Path, + indexed_db: Path, + coords_count: int, + available_count: int, + holes_count: int, + raw_available_verified: int | None, + raw_holes_verified: int | None, + neighborhood_checked: int | None, + neighborhood_examples: list[str], + present_timestamp: int, + missing_timestamp: int, + excluded_igbp: tuple[str, ...], +) -> None: + lines = [ + "Raw ERA5 hole map diagnostic", + "", + f"raw_db: {raw_db.resolve()}", + f"indexed_db_used_for_fast_lookup: {indexed_db.resolve()}", + f"present_timestamp: {present_timestamp} ({timestamp_label(present_timestamp)})", + f"missing_timestamp: {missing_timestamp} ({timestamp_label(missing_timestamp)})", + f"excluded_igbp: {', '.join(excluded_igbp) if excluded_igbp else ''}", + "", + f"coords_considered_from_raw_coord_data: {coords_count:,}", + f"coords_present_at_missing_timestamp: {available_count:,}", + f"holes_present_at_previous_missing_at_next: {holes_count:,}", + "", + "raw_verified_present_at_missing_row_ids: " + + ( + f"{raw_available_verified:,}" + if raw_available_verified is not None + else "skipped by default; pass --verify-available-row-ids" + ), + "raw_verified_hole_present_timestamp_row_ids: " + + ( + f"{raw_holes_verified:,}" + if raw_holes_verified is not None + else "not run in this pass" + ), + "raw_neighborhood_absence_checks: " + + ( + f"{neighborhood_checked:,}" + if neighborhood_checked is not None + else "not run in this pass" + ), + "", + "raw_neighborhood_examples:", + ] + lines.extend(f" - {example}" for example in neighborhood_examples) + output_report.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_verify_report( + output_report: Path, + raw_db: Path, + cache_path: Path, + cache_start: int, + cache_limit: int | None, + cache_total: int, + raw_holes_verified: int, + neighborhood_checked: int, + neighborhood_examples: list[str], +) -> None: + lines = [ + "Raw ERA5 hole cache verification", + "", + f"raw_db: {raw_db.resolve()}", + f"hole_cache: {cache_path.resolve()}", + f"cache_total_rows: {cache_total:,}", + f"cache_start: {cache_start:,}", + "cache_limit: " + (f"{cache_limit:,}" if cache_limit is not None else "all remaining"), + f"raw_verified_hole_present_timestamp_row_ids: {raw_holes_verified:,}", + f"raw_neighborhood_absence_checks: {neighborhood_checked:,}", + "", + "raw_neighborhood_examples:", + ] + lines.extend(f" - {example}" for example in neighborhood_examples) + output_report.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + args = parse_args() + raw_db = args.raw_db.expanduser().resolve() + indexed_db = args.indexed_db.expanduser().resolve() + output_dir = args.output_dir.expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if not raw_db.exists(): + raise FileNotFoundError(f"Raw DB not found: {raw_db}") + if not indexed_db.exists(): + raise FileNotFoundError(f"Indexed DB not found: {indexed_db}") + + if args.verify_cache is not None: + cache_path = args.verify_cache.expanduser().resolve() + all_holes = load_hole_cache(cache_path) + if args.verify_cache_start < 0: + raise ValueError("--verify-cache-start must be >= 0") + if args.verify_cache_limit is not None and args.verify_cache_limit <= 0: + raise ValueError("--verify-cache-limit must be positive when provided") + end = ( + len(all_holes) + if args.verify_cache_limit is None + else min(len(all_holes), args.verify_cache_start + args.verify_cache_limit) + ) + holes = all_holes[args.verify_cache_start : end] + suffix = ( + "_raw_verify" + if args.verify_cache_start == 0 and args.verify_cache_limit is None + else f"_raw_verify_{args.verify_cache_start:05d}_{end:05d}" + ) + verify_report = cache_path.with_name(cache_path.stem + f"{suffix}.txt") + print( + f"verifying cached holes {args.verify_cache_start:,}:{end:,} " + f"of {len(all_holes):,} against raw db: {raw_db}", + flush=True, + ) + with connect_readonly(raw_db) as raw_conn, connect_readonly(indexed_db) as indexed_conn: + raw_holes_verified = verify_raw_row_ids(raw_conn, holes, label="holes") + neighborhood_checked, neighborhood_examples = verify_raw_hole_neighborhoods( + raw_conn, + indexed_conn, + holes, + args.missing_timestamp, + args.raw_neighborhood_samples, + ) + write_verify_report( + verify_report, + raw_db, + cache_path, + args.verify_cache_start, + args.verify_cache_limit, + len(all_holes), + raw_holes_verified, + neighborhood_checked, + neighborhood_examples, + ) + print(f"wrote {verify_report}", flush=True) + return 0 + + excluded_igbp = tuple(code.upper() for code in args.exclude_igbp) + stem = output_stem(args.present_timestamp, args.missing_timestamp) + output_png = output_dir / f"{stem}.png" + output_report = output_dir / f"{stem}.txt" + output_holes_csv = output_dir / f"{stem}_holes.csv" + + print(f"raw db: {raw_db}", flush=True) + print(f"indexed db for fast row-id lookup: {indexed_db}", flush=True) + print(f"output png: {output_png}", flush=True) + + with connect_readonly(raw_db) as raw_conn, connect_readonly(indexed_db) as indexed_conn: + coords_count, available_at_missing, holes = classify_raw_coords_with_indexed_db( + indexed_conn, + raw_db, + excluded_igbp, + args.present_timestamp, + args.missing_timestamp, + ) + print(f"loaded {coords_count:,} coords from raw coord_data after exclusions", flush=True) + print( + f"classification done: available={len(available_at_missing):,}, " + f"holes={len(holes):,}", + flush=True, + ) + + raw_holes_verified = None + raw_available_verified = None + neighborhood_checked = None + neighborhood_examples: list[str] = [] + if not args.skip_raw_verification: + raw_holes_verified = verify_raw_row_ids(raw_conn, holes, label="holes") + raw_available_verified = ( + verify_raw_row_ids(raw_conn, available_at_missing, label="available") + if args.verify_available_row_ids + else None + ) + neighborhood_checked, neighborhood_examples = verify_raw_hole_neighborhoods( + raw_conn, + indexed_conn, + holes, + args.missing_timestamp, + args.raw_neighborhood_samples, + ) + + render_map( + output_png, + available_at_missing, + holes, + args.present_timestamp, + args.missing_timestamp, + args.width, + args.height, + ) + write_hole_cache(output_holes_csv, holes) + write_report( + output_report, + raw_db, + indexed_db, + coords_count, + len(available_at_missing), + len(holes), + raw_available_verified, + raw_holes_verified, + neighborhood_checked, + neighborhood_examples, + args.present_timestamp, + args.missing_timestamp, + excluded_igbp, + ) + + print(f"wrote {output_png}", flush=True) + print(f"wrote {output_holes_csv}", flush=True) + print(f"wrote {output_report}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.png b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.png new file mode 100644 index 0000000000000000000000000000000000000000..f6acc58e016839ea8605fa70a488bcffa471ef6e GIT binary patch literal 69145 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,*@M;)GQc$!KT_A zNVeheV3s*0nEZ`A>>&%Q!nhFX+!{fJBU&8mP3ootNUP1j{zv1uVX$`z@cuMf+UbCm z>&sQ$&Q~TN4=D3xYv3c zUmc{n?={>Fr5Eu4L-QXOsyS7n`PzBn#C@nv%>X?*YF|ERPdb`ST7rxBEzeAm>RGA} z1ot=^teI`@GHfqcg5CW@|K79@-xRVGs6T+x2nq2S09+*Sn5iWR&GwU#GU#Ik&Ro33 z555eP;zFYNXI^1d?k8K+&SRxE1o*4Ixc&8l3)^`%eNU4W*Te|+q0a@L7p>dMCs+wm z%<*o=7^w0hTi3Da;%ePQm>g!soAlZ`xsnpQdRt&;R!yR-CS@+nrLO5U4 zk(uR`rJrg~05@g&=!eze546G*ps*Pu-zZs{tGGq2odBnSaHMZgENa%!rHQ6osDtBg zI5(uZh=mUz*92$x|KXi%ga#n&@cQQv9O(k20{ZK}-v1x@uzT#PFgMySzx51d+dWD7 KyM^L9&;J(<=+T@2 literal 0 HcmV?d00001 diff --git a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt new file mode 100644 index 0000000..6e123d4 --- /dev/null +++ b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt @@ -0,0 +1,17 @@ +Raw ERA5 hole map diagnostic + +raw_db: /project/6100839/luislara/ep/data/2016_2017/era5_2016_2017.db +indexed_db_used_for_fast_lookup: /project/6100839/luislara/ep/data/2016_2017/era5_2016_2017.db +present_timestamp: 20170103080000 (2017-01-03 08:00) +missing_timestamp: 20170103090000 (2017-01-03 09:00) +excluded_igbp: WAT, SNO, BSV, URB, CRO, CVM + +coords_considered_from_raw_coord_data: 174,132 +coords_present_at_missing_timestamp: 167,259 +holes_present_at_previous_missing_at_next: 0 + +raw_verified_present_at_missing_row_ids: skipped by default; pass --verify-available-row-ids +raw_verified_hole_present_timestamp_row_ids: 0 +raw_neighborhood_absence_checks: 0 + +raw_neighborhood_examples: diff --git a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv new file mode 100644 index 0000000..29fe1f6 --- /dev/null +++ b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv @@ -0,0 +1 @@ +coord_id,lat,lon,igbp,row_id,row_timestamp diff --git a/scripts/viz_netcdf_structure.py b/era5_pipeline/plot/viz_netcdf_structure.py similarity index 100% rename from scripts/viz_netcdf_structure.py rename to era5_pipeline/plot/viz_netcdf_structure.py diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..cf94453 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,13 @@ +module load python/3.12 +python -m venv $SCRATCH/env/ecoperceiver +source $SCRATCH/env/ecoperceiver/bin/activate + +module load StdEnv/2023 gcc/12.3 +module load hdf5/1.14.5 netcdf/4.9.2 + +ssh tc11101 +cd ~/links/scratch/EcoPerceiver/experiments + +tensorboard --logdir tensorboard/ + +python submit_CC.py --prefix final_v3 --hours 24 --n_nodes 4 diff --git a/scripts/run_era5_one_year_inference.sh b/scripts/run_era5_one_year_inference.sh index bae0866..5c60d89 100755 --- a/scripts/run_era5_one_year_inference.sh +++ b/scripts/run_era5_one_year_inference.sh @@ -7,9 +7,9 @@ usage() { echo "Example: $0 2017" >&2 } -YEAR="${YEAR:-}" +YEAR="${YEAR:-2017}" MODE="${MODE:-parallel}" -POST_FORMAT="${POST_FORMAT:-csv}" +POST_FORMAT="${POST_FORMAT:-netcdf}" DRY_RUN="${DRY_RUN:-0}" RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" From 789d20a357fc0c8621c50d943f1d645f412ac37e Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Sun, 28 Jun 2026 12:23:45 -0400 Subject: [PATCH 09/14] pipeline working --- .gitignore | 1 + era5_pipeline/download_era5.py | 21 ++- era5_pipeline/pipeline.py | 61 ++++++- era5_pipeline/pipeline.sh | 6 +- era5_pipeline/pipeline_config.yml | 19 +- era5_pipeline/plot/igbp_coord_map.png | Bin 94039 -> 0 bytes era5_pipeline/plot/plot_igbp_map.py | 2 +- ...holes_20170103090000_vs_20170103080000.png | Bin 69145 -> 0 bytes ...holes_20170103090000_vs_20170103080000.txt | 17 -- ...20170103090000_vs_20170103080000_holes.csv | 1 - era5_pipeline/tests/test_era5_timezone.py | 169 ++++++++++++++++++ 11 files changed, 262 insertions(+), 35 deletions(-) delete mode 100644 era5_pipeline/plot/igbp_coord_map.png delete mode 100644 era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.png delete mode 100644 era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt delete mode 100644 era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv create mode 100644 era5_pipeline/tests/test_era5_timezone.py diff --git a/.gitignore b/.gitignore index e72fdfa..2e12fce 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,4 @@ scripts/modis/logs/ scripts/run_era5.sh era5_predictions.csv +era5_pipeline/plot/tmp/ diff --git a/era5_pipeline/download_era5.py b/era5_pipeline/download_era5.py index 54b03bd..1247e50 100644 --- a/era5_pipeline/download_era5.py +++ b/era5_pipeline/download_era5.py @@ -6,8 +6,9 @@ to predictor variables, then write ``coord_data`` and ``ec_data`` tables. The post-processing deliberately computes local time with minute-precision -UTC offsets. This avoids the India/Australia bug caused by assuming every -time-zone offset is a whole number of hours. +UTC offsets for cyclic time features. Local-policy row timestamps are rounded +onto an hourly grid by default, because downstream ERA5 lookup and cube output +expect every coordinate to share hourly timestamp values. """ from __future__ import annotations @@ -802,6 +803,11 @@ def __init__(self, config: dict[str, Any]): self.timestamp_policy = str(timezone_config.get("timestamp_policy", "local")).lower() self.method = str(timezone_config.get("method", "timezonefinder")).lower() self.fallback = str(timezone_config.get("fallback", "longitude_quarter_hour")).lower() + self.timestamp_grid = str(timezone_config.get("timestamp_grid", "hourly")).lower() + if self.timestamp_grid not in {"hourly", "exact"}: + raise SystemExit( + "process_era5.timezone.timestamp_grid must be 'hourly' or 'exact'." + ) self.require_timezonefinder = bool(timezone_config.get("require_timezonefinder", True)) self.zone_cache: dict[tuple[float, float], str | None] = {} self.zone_offset_cache: dict[tuple[str, datetime], int | None] = {} @@ -1071,6 +1077,11 @@ def timestamp_bound_to_int(timestamp: "pd.Timestamp | None") -> int | None: ts = ts.tz_convert("UTC").tz_localize(None) return int(ts.strftime("%Y%m%d%H%M%S")) + def timestamp_for_local_grid(self, local_ts: "pd.Timestamp") -> "pd.Timestamp": + if self.timezone_resolver.timestamp_grid == "exact": + return local_ts + return (local_ts + pd.Timedelta(minutes=30)).floor("h") + def process_groups(self, netcdf_dir: Path, limit_groups: int | None = None) -> int: group_dirs = list(iter_netcdf_group_dirs(netcdf_dir)) if limit_groups is not None: @@ -1245,7 +1256,8 @@ def coord_insert_order_for_times( keep = np.zeros(point_count, dtype=bool) for offset in np.unique(offset_minutes): local_ts = utc_naive + pd.Timedelta(minutes=int(offset)) - local_timestamp_value = int(local_ts.strftime("%Y%m%d%H%M%S")) + timestamp_ts = self.timestamp_for_local_grid(local_ts) + local_timestamp_value = int(timestamp_ts.strftime("%Y%m%d%H%M%S")) if start is not None and local_timestamp_value < start: continue if end is not None and local_timestamp_value > end: @@ -1373,7 +1385,8 @@ def time_fields_for_block( for offset in np.unique(offset_minutes): offset_mask = offset_minutes == offset local_ts = utc_naive + pd.Timedelta(minutes=int(offset)) - local_timestamp_value = int(local_ts.strftime("%Y%m%d%H%M%S")) + timestamp_ts = self.timestamp_for_local_grid(local_ts) + local_timestamp_value = int(timestamp_ts.strftime("%Y%m%d%H%M%S")) tod_value = ( float(local_ts.hour) + float(local_ts.minute) / 60.0 diff --git a/era5_pipeline/pipeline.py b/era5_pipeline/pipeline.py index 0a86960..d9d7f01 100644 --- a/era5_pipeline/pipeline.py +++ b/era5_pipeline/pipeline.py @@ -4,10 +4,12 @@ from __future__ import annotations import argparse +from datetime import datetime import os import shlex import subprocess import sys +import time from dataclasses import dataclass from pathlib import Path from typing import Any @@ -32,6 +34,13 @@ "download_era5", "process_era5", ) +TIMED_STEPS = frozenset( + ( + "index_era5", + "assign_igbp_from_modis", + "process_modis", + ) +) @dataclass(frozen=True) @@ -667,6 +676,46 @@ def run_command(command: list[str], dry_run: bool) -> None: subprocess.run(command, cwd=REPO_ROOT, env=env, check=True) +def timestamp_now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def format_elapsed(seconds: float) -> str: + total_seconds = int(round(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + +def run_step(step: str, args: argparse.Namespace) -> float | None: + timed = step in TIMED_STEPS and not args.dry_run + if not timed: + run_command(command_for_step(step, args), dry_run=args.dry_run) + return None + + started_at = timestamp_now() + start_time = time.monotonic() + print(f"[timing] {step} started_at={started_at}", flush=True) + try: + run_command(command_for_step(step, args), dry_run=False) + except BaseException: + elapsed = time.monotonic() - start_time + print( + f"[timing] {step} failed_at={timestamp_now()} " + f"elapsed={format_elapsed(elapsed)}", + flush=True, + ) + raise + + elapsed = time.monotonic() - start_time + print( + f"[timing] {step} finished_at={timestamp_now()} " + f"elapsed={format_elapsed(elapsed)}", + flush=True, + ) + return elapsed + + def main() -> int: args = parse_args() if args.list_steps: @@ -676,9 +725,19 @@ def main() -> int: steps = list(args.steps) print(f"Config: {args.config_path}") print(f"Selected pipeline steps: {' '.join(steps)}") + timed_step_elapsed: list[tuple[str, float]] = [] for step in steps: print(f"\n==> {step}: {STEPS[step].description}", flush=True) - run_command(command_for_step(step, args), dry_run=args.dry_run) + elapsed = run_step(step, args) + if elapsed is not None: + timed_step_elapsed.append((step, elapsed)) + + if timed_step_elapsed: + print("\nPipeline timing summary:", flush=True) + for step, elapsed in timed_step_elapsed: + print(f" {step}: {format_elapsed(elapsed)}", flush=True) + total_elapsed = sum(elapsed for _step, elapsed in timed_step_elapsed) + print(f" timed_steps_total: {format_elapsed(total_elapsed)}", flush=True) if args.dry_run: print("\nDry run only; no pipeline commands were executed.") diff --git a/era5_pipeline/pipeline.sh b/era5_pipeline/pipeline.sh index 29c4a52..99a2d90 100755 --- a/era5_pipeline/pipeline.sh +++ b/era5_pipeline/pipeline.sh @@ -4,9 +4,9 @@ #SBATCH --mem=128G #SBATCH --cpus-per-task=8 #SBATCH --time=24:00:00 -#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j.out -#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j.error -#SBATCH --job-name=era5-pipeline +#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j_3.out +#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j_3.error +#SBATCH --job-name=era5-pipeline3 #SBATCH --account=aip-pal set -euo pipefail diff --git a/era5_pipeline/pipeline_config.yml b/era5_pipeline/pipeline_config.yml index d9573d4..d9fde70 100644 --- a/era5_pipeline/pipeline_config.yml +++ b/era5_pipeline/pipeline_config.yml @@ -1,18 +1,20 @@ pipeline: steps: - # - download_era5 + - download_era5 # - process_era5 # - download_modis - - index_era5 - - assign_igbp_from_modis - - process_modis + # - index_era5 + # - assign_igbp_from_modis + # - process_modis dry_run: false python: null -start_date: "2016-01-01" -end_date: "2017-12-31" +# start_date: "2016-01-01" +# end_date: "2017-12-31" # start_date: "2014-01-01" # end_date: "2015-12-31" +start_date: "2012-01-01" +end_date: "2013-12-31" paths: data_root: /home/l/luislara/links/projects/aip-pal/luislara/ep/data @@ -62,9 +64,10 @@ process_era5: timezone: enabled: true - # "local" stores wall-clock timestamps per coordinate. Half-hour offsets - # produce timestamps such as YYYYMMDDHH3000. + # "local" stores wall-clock timestamps per coordinate. The hourly grid keeps + # all coordinates on YYYYMMDDHH0000 values while TOD/DOY use exact offsets. timestamp_policy: local + timestamp_grid: hourly method: timezonefinder require_timezonefinder: true fallback: longitude_quarter_hour diff --git a/era5_pipeline/plot/igbp_coord_map.png b/era5_pipeline/plot/igbp_coord_map.png deleted file mode 100644 index 16f78a397ff4ff8e74bc20ae7cd76a7b30e956f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94039 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 z 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%0DJipI>Il65 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,*@M;)GQc$!KT_A zNVeheV3s*0nEZ`A>>&%Q!nhFX+!{fJBU&8mP3ootNUP1j{zv1uVX$`z@cuMf+UbCm z>&sQ$&Q~TN4=D3xYv3c zUmc{n?={>Fr5Eu4L-QXOsyS7n`PzBn#C@nv%>X?*YF|ERPdb`ST7rxBEzeAm>RGA} z1ot=^teI`@GHfqcg5CW@|K79@-xRVGs6T+x2nq2S09+*Sn5iWR&GwU#GU#Ik&Ro33 z555eP;zFYNXI^1d?k8K+&SRxE1o*4Ixc&8l3)^`%eNU4W*Te|+q0a@L7p>dMCs+wm z%<*o=7^w0hTi3Da;%ePQm>g!soAlZ`xsnpQdRt&;R!yR-CS@+nrLO5U4 zk(uR`rJrg~05@g&=!eze546G*ps*Pu-zZs{tGGq2odBnSaHMZgENa%!rHQ6osDtBg zI5(uZh=mUz*92$x|KXi%ga#n&@cQQv9O(k20{ZK}-v1x@uzT#PFgMySzx51d+dWD7 KyM^L9&;J(<=+T@2 diff --git a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt deleted file mode 100644 index 6e123d4..0000000 --- a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000.txt +++ /dev/null @@ -1,17 +0,0 @@ -Raw ERA5 hole map diagnostic - -raw_db: /project/6100839/luislara/ep/data/2016_2017/era5_2016_2017.db -indexed_db_used_for_fast_lookup: /project/6100839/luislara/ep/data/2016_2017/era5_2016_2017.db -present_timestamp: 20170103080000 (2017-01-03 08:00) -missing_timestamp: 20170103090000 (2017-01-03 09:00) -excluded_igbp: WAT, SNO, BSV, URB, CRO, CVM - -coords_considered_from_raw_coord_data: 174,132 -coords_present_at_missing_timestamp: 167,259 -holes_present_at_previous_missing_at_next: 0 - -raw_verified_present_at_missing_row_ids: skipped by default; pass --verify-available-row-ids -raw_verified_hole_present_timestamp_row_ids: 0 -raw_neighborhood_absence_checks: 0 - -raw_neighborhood_examples: diff --git a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv b/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv deleted file mode 100644 index 29fe1f6..0000000 --- a/era5_pipeline/plot/tmp/raw_era5_holes_20170103090000_vs_20170103080000_holes.csv +++ /dev/null @@ -1 +0,0 @@ -coord_id,lat,lon,igbp,row_id,row_timestamp diff --git a/era5_pipeline/tests/test_era5_timezone.py b/era5_pipeline/tests/test_era5_timezone.py new file mode 100644 index 0000000..df78018 --- /dev/null +++ b/era5_pipeline/tests/test_era5_timezone.py @@ -0,0 +1,169 @@ +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +try: + import pandas as pd + import xarray as xr +except ModuleNotFoundError: + pd = None + xr = None + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ERA5_PIPELINE_DIR = REPO_ROOT / "era5_pipeline" +sys.path.insert(0, str(ERA5_PIPELINE_DIR)) +sys.path.insert(0, str(REPO_ROOT)) + +import download_era5 + + +HALF_HOUR_COORDS = { + "india": (22.0, 78.0), + "australia_darwin": (-12.46, 130.84), + "newfoundland": (48.5, -53.5), +} + + +def make_config(root: Path, *, timestamp_grid: str = "hourly") -> dict: + return { + "start_date": "2017-01-03", + "end_date": "2017-01-03", + "paths": { + "output_dir": str(root), + "db_path": str(root / "era5_timezone_test.db"), + "netcdf_dir": str(root / "era5_data"), + }, + "process_era5": { + "recreate_db": True, + "batch_size": 1_000, + "insert_hours_per_batch": 4, + "xarray_engine": "h5netcdf", + "timezone": { + "enabled": True, + "timestamp_policy": "local", + "timestamp_grid": timestamp_grid, + "method": "timezonefinder", + "require_timezonefinder": True, + "fallback": "longitude_quarter_hour", + "local_window": {"enabled": True}, + }, + "land_sea_mask": {"enabled": False}, + }, + } + + +@unittest.skipIf( + pd is None or xr is None or download_era5.TimezoneFinder is None, + "pandas, xarray, and timezonefinder are required", +) +class Era5TimezoneGridTest(unittest.TestCase): + def test_half_hour_zones_use_hourly_timestamp_grid(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = make_config(root) + grid = self.make_grid(config) + times = [ + pd.Timestamp("2017-01-03T03:00:00"), # India 08:30 -> 09:00 + pd.Timestamp("2017-01-02T23:00:00"), # Darwin 08:30 -> 09:00 + pd.Timestamp("2017-01-03T12:00:00"), # Newfoundland 08:30 -> 09:00 + ] + + timestamp, _, _, tod = self.make_processor(config).time_fields_for_block(grid, times) + + self.assertEqual(timestamp[0, 0], 20170103090000) + self.assertEqual(timestamp[1, 1], 20170103090000) + self.assertEqual(timestamp[2, 2], 20170103090000) + self.assertAlmostEqual(tod[0, 0], 9.5) + + def test_exact_timestamp_grid_preserves_minute_offsets(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = make_config(root, timestamp_grid="exact") + grid = self.make_grid(config) + + timestamp, _, _, _ = self.make_processor(config).time_fields_for_block( + grid, + [pd.Timestamp("2017-01-03T03:00:00")], + ) + + self.assertEqual(timestamp[0, 0], 20170103083000) + + def test_tiny_database_has_half_hour_zone_rows_at_0900(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = make_config(root) + self.write_tiny_netcdf(root / "era5_data" / "smoke") + db_path = root / "era5_timezone_test.db" + + with sqlite3.connect(db_path) as conn: + download_era5.init_sqlite(conn, config) + processor = download_era5.Era5PostProcessor(config, conn) + processor.process_groups(root / "era5_data") + conn.commit() + + minute_timestamp_rows = conn.execute( + "SELECT COUNT(*) FROM ec_data WHERE timestamp % 10000 != 0" + ).fetchone()[0] + self.assertEqual(minute_timestamp_rows, 0) + + for lat, lon in HALF_HOUR_COORDS.values(): + rows = conn.execute( + """ + SELECT COUNT(*) + FROM ec_data ec + JOIN coord_data coord ON coord.coord_id = ec.coord_id + WHERE ABS(coord.lat - ?) < 1e-6 + AND ABS(coord.lon - ?) < 1e-6 + AND ec.timestamp = 20170103090000; + """, + (lat, lon), + ).fetchone()[0] + self.assertEqual(rows, 1, (lat, lon)) + + def make_processor(self, config: dict) -> download_era5.Era5PostProcessor: + conn = sqlite3.connect(":memory:") + download_era5.init_sqlite(conn, config) + self.addCleanup(conn.close) + return download_era5.Era5PostProcessor(config, conn) + + def make_grid(self, config: dict) -> download_era5.Era5GroupGrid: + lat = np.asarray([coord[0] for coord in HALF_HOUR_COORDS.values()], dtype=float) + lon = np.asarray([coord[1] for coord in HALF_HOUR_COORDS.values()], dtype=float) + processor = self.make_processor(config) + return download_era5.Era5GroupGrid( + lat=lat, + lon=lon, + land_indices=np.arange(len(lat), dtype=np.int64), + spatial_shape=(len(lat), 1), + fallback_offsets=processor.timezone_resolver.fallback_offsets_for_longitudes(lon), + timezone_groups=processor.timezone_groups_for_coordinates(lat, lon), + coord_id=np.arange(1, len(lat) + 1, dtype=np.int64), + ) + + def write_tiny_netcdf(self, group_dir: Path) -> None: + group_dir.mkdir(parents=True, exist_ok=True) + lats = np.asarray([22.0, -12.46, 48.5, 0.0], dtype=float) + lons = np.asarray([78.0, 130.84, -53.5, 0.0], dtype=float) + valid_time = pd.date_range("2017-01-02T22:00:00", "2017-01-03T12:00:00", freq="h") + shape = (len(valid_time), len(lats), len(lons)) + values = np.ones(shape, dtype=np.float32) + ds = xr.Dataset( + { + "geopotential": (("valid_time", "latitude", "longitude"), values), + }, + coords={ + "valid_time": valid_time, + "latitude": lats, + "longitude": lons, + }, + ) + ds.to_netcdf(group_dir / "tiny.nc", engine="h5netcdf") + + +if __name__ == "__main__": + unittest.main() From 32ba1f011e36b366376ff5087e5c5796b962459a Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Sun, 28 Jun 2026 16:38:58 -0400 Subject: [PATCH 10/14] scripts/era5 created --- eval/merge_era5_shards.py | 5 +- eval/test_era5.py | 2 +- scripts/era5/build_year_nc.sh | 290 ++++++++++++++++++ .../merge_prediction_shards.sh} | 55 +++- scripts/era5/push_year_nc.sh | 266 ++++++++++++++++ scripts/{run_era5.sh => era5/run.sh} | 12 +- .../run_multi_gpu.sh} | 84 ++++- scripts/era5/submit_year.sh | 286 +++++++++++++++++ scripts/run_era5_one_year_inference.sh | 133 -------- 9 files changed, 957 insertions(+), 176 deletions(-) create mode 100755 scripts/era5/build_year_nc.sh rename scripts/{post_processing_era5.sh => era5/merge_prediction_shards.sh} (72%) create mode 100755 scripts/era5/push_year_nc.sh rename scripts/{run_era5.sh => era5/run.sh} (74%) mode change 100644 => 100755 rename scripts/{run_era5_multi_gpu.sh => era5/run_multi_gpu.sh} (57%) create mode 100755 scripts/era5/submit_year.sh delete mode 100755 scripts/run_era5_one_year_inference.sh diff --git a/eval/merge_era5_shards.py b/eval/merge_era5_shards.py index 59eee6b..43bd6f4 100644 --- a/eval/merge_era5_shards.py +++ b/eval/merge_era5_shards.py @@ -135,10 +135,7 @@ def parse_prediction_targets(values: list[str] | None) -> tuple[str, ...] | None prediction_targets = [] for value in values: - for raw_target in value.split(","): - target = raw_target.strip() - if not target: - continue + for target in value.replace(",", " ").split(): if not target.startswith("pred_"): target = f"pred_{target}" prediction_targets.append(target) diff --git a/eval/test_era5.py b/eval/test_era5.py index 348d913..c4c54eb 100644 --- a/eval/test_era5.py +++ b/eval/test_era5.py @@ -74,7 +74,7 @@ def parse_requested_prediction_targets(values: list[str] | None) -> tuple[str, . requested = [] for value in values: - requested.extend(item.strip() for item in value.split(",") if item.strip()) + requested.extend(value.replace(",", " ").split()) requested = list(dict.fromkeys(requested)) if not requested: diff --git a/scripts/era5/build_year_nc.sh b/scripts/era5/build_year_nc.sh new file mode 100755 index 0000000..f46fb9c --- /dev/null +++ b/scripts/era5/build_year_nc.sh @@ -0,0 +1,290 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +if [[ -n "${ECOPERCEIVER_ENV:-}" && -f "$ECOPERCEIVER_ENV/bin/activate" ]]; then + # Optional override for standalone use outside the usual SCRATCH environment. + source "$ECOPERCEIVER_ENV/bin/activate" +elif [[ -n "${SCRATCH:-}" && -f "$SCRATCH/env/ecoperceiver/bin/activate" ]]; then + source "$SCRATCH/env/ecoperceiver/bin/activate" +fi + +export PYTHONUNBUFFERED=1 + +python3 - "$@" <<'PY' +import argparse +import os +from datetime import datetime, timezone +from pathlib import Path +import sys + + +DEFAULT_RUN_PATH = ( + "experiments/runs/" + "final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" +) +TIME_DIM = "valid_time" +SPATIAL_DIMS = ("latitude", "longitude") +CUBE_DIMS = (TIME_DIM, *SPATIAL_DIMS) +CHUNK_TARGETS = (186, 31, 360) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="build_year_nc.sh", + description=( + "Unify four quarterly EcoPerceiver ERA5 NetCDF outputs into one " + "calendar-year NetCDF file." + ) + ) + parser.add_argument("year", nargs="?", help="Four-digit year to assemble.") + parser.add_argument("--year", dest="year_option", help="Four-digit year to assemble.") + parser.add_argument( + "--run-path", + default=os.environ.get("RUN_PATH", DEFAULT_RUN_PATH), + help="EcoPerceiver run directory. Default: RUN_PATH env or the repo default run.", + ) + parser.add_argument( + "--input-dir", + default=os.environ.get("INPUT_DIR"), + help="Directory containing quarter .nc files. Default: /eval.", + ) + parser.add_argument( + "--output-path", + default=os.environ.get("OUTPUT_PATH"), + help="Yearly NetCDF output path. Default: /era5_predictions_.nc.", + ) + parser.add_argument( + "--engine", + default=os.environ.get("XARRAY_ENGINE", "h5netcdf"), + help="xarray NetCDF engine. Default: h5netcdf.", + ) + parser.add_argument( + "--inputs", + nargs=4, + metavar="PATH", + help=( + "Explicit Q1 Q2 Q3 Q4 NetCDF paths. If omitted, paths are inferred " + "from the quarterly post-processing output naming convention." + ), + ) + parser.add_argument( + "--allow-missing", + action="store_true", + help="Use the existing quarter files and skip missing inferred inputs.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Replace an existing output file.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print input and output paths without writing the assembled file.", + ) + args = parser.parse_args() + + year = args.year_option or args.year + if year is None: + parser.error("YEAR is required as a positional argument or --year.") + if not year.isdigit() or len(year) != 4: + parser.error(f"YEAR must be a four-digit year, got: {year}") + args.year = year + return args + + +def quarter_paths(year: str, input_dir: Path) -> list[Path]: + ranges = ( + (f"{year}-01-01", f"{year}-03-31"), + (f"{year}-04-01", f"{year}-06-30"), + (f"{year}-07-01", f"{year}-09-30"), + (f"{year}-10-01", f"{year}-12-31"), + ) + paths = [] + for start, end in ranges: + date_tag = f"{start.replace('-', '')}_to_{end.replace('-', '')}" + paths.append(input_dir / f"era5_predictions_{date_tag}.nc") + return paths + + +def resolve_paths(args: argparse.Namespace) -> tuple[list[Path], Path]: + run_path = Path(args.run_path).expanduser() + input_dir = Path(args.input_dir).expanduser() if args.input_dir else run_path / "eval" + if args.inputs: + inputs = [Path(path).expanduser() for path in args.inputs] + else: + inputs = quarter_paths(args.year, input_dir) + + output_path = ( + Path(args.output_path).expanduser() + if args.output_path + else input_dir / f"era5_predictions_{args.year}.nc" + ) + return inputs, output_path + + +def existing_inputs(inputs: list[Path], allow_missing: bool) -> list[Path]: + missing = [path for path in inputs if not path.is_file()] + if missing and not allow_missing: + missing_list = "\n ".join(str(path) for path in missing) + raise FileNotFoundError(f"Missing quarter NetCDF input(s):\n {missing_list}") + + present = [path for path in inputs if path.is_file()] + if not present: + raise FileNotFoundError("No quarter NetCDF inputs found.") + if allow_missing and len(present) < len(inputs): + print(f"Skipping {len(inputs) - len(present)} missing quarter input(s).") + return present + + +def has_duplicate_times(dataset, pandas) -> bool: + if TIME_DIM not in dataset.coords: + raise RuntimeError(f"Assembled dataset is missing required coordinate: {TIME_DIM}") + index = dataset.indexes.get(TIME_DIM) + if index is None: + index = pandas.Index(dataset[TIME_DIM].values) + return bool(index.has_duplicates) + + +def build_encoding(dataset, numpy) -> dict[str, dict[str, object]]: + encoding: dict[str, dict[str, object]] = {} + + if TIME_DIM in dataset.variables: + if numpy.issubdtype(dataset[TIME_DIM].dtype, numpy.datetime64): + encoding[TIME_DIM] = { + "dtype": "int64", + "units": "seconds since 1970-01-01", + "calendar": "proleptic_gregorian", + } + else: + encoding[TIME_DIM] = {"dtype": "int64"} + + for coord in SPATIAL_DIMS: + if coord in dataset.variables: + encoding[coord] = {"dtype": "float64", "_FillValue": numpy.nan} + + cube_chunks = None + if all(dim in dataset.sizes for dim in CUBE_DIMS): + cube_chunks = tuple( + max(1, min(int(dataset.sizes[dim]), target)) + for dim, target in zip(CUBE_DIMS, CHUNK_TARGETS) + ) + + for name, variable in dataset.data_vars.items(): + if variable.dtype.kind not in {"f", "i", "u"}: + continue + + variable_encoding: dict[str, object] = { + "zlib": True, + "complevel": 1, + "shuffle": True, + } + if variable.dtype.kind == "f": + variable_encoding["dtype"] = str(variable.dtype) + variable_encoding["_FillValue"] = ( + numpy.float32(numpy.nan) if variable.dtype == numpy.float32 else numpy.nan + ) + if variable.dims == CUBE_DIMS and cube_chunks is not None: + variable_encoding["chunksizes"] = cube_chunks + elif variable.dims == SPATIAL_DIMS and cube_chunks is not None: + variable_encoding["chunksizes"] = cube_chunks[1:] + encoding[name] = variable_encoding + + return encoding + + +def assemble_year(inputs: list[Path], output_path: Path, engine: str, overwrite: bool) -> None: + try: + import numpy as np + import pandas as pd + import xarray as xr + except ImportError as exc: + raise RuntimeError( + "Merging NetCDF files requires numpy, pandas, and xarray in the active environment." + ) from exc + + if output_path.exists() and not overwrite: + raise FileExistsError(f"Output already exists. Use --overwrite to replace it: {output_path}") + + output_path.parent.mkdir(parents=True, exist_ok=True) + datasets = [] + try: + for path in inputs: + ds = xr.open_dataset(path, engine=engine) + if TIME_DIM not in ds.dims and TIME_DIM not in ds.coords: + raise RuntimeError(f"{path} is missing required time dimension: {TIME_DIM}") + datasets.append(ds) + + assembled = xr.concat( + datasets, + dim=TIME_DIM, + data_vars="minimal", + coords="minimal", + compat="override", + join="exact", + combine_attrs="override", + ) + assembled = assembled.sortby(TIME_DIM) + if has_duplicate_times(assembled, pd): + raise RuntimeError( + f"Duplicate {TIME_DIM} coordinate values found after assembly. " + "Check for overlapping quarter files." + ) + + history = assembled.attrs.get("history", "") + assembly_note = ( + f"{datetime.now(timezone.utc).isoformat()} assembled quarterly " + "EcoPerceiver ERA5 NetCDF outputs into one calendar-year file" + ) + assembled.attrs["history"] = f"{history}\n{assembly_note}".strip() + assembled.attrs["assembled_input_files"] = " ".join(str(path) for path in inputs) + assembled.attrs["num_assembled_input_files"] = len(inputs) + + tmp_path = output_path.with_suffix(output_path.suffix + ".tmp") + tmp_path.unlink(missing_ok=True) + try: + assembled.to_netcdf(tmp_path, engine=engine, encoding=build_encoding(assembled, np)) + tmp_path.replace(output_path) + finally: + tmp_path.unlink(missing_ok=True) + assembled.close() + finally: + for dataset in datasets: + dataset.close() + + +def main() -> int: + args = parse_args() + inputs, output_path = resolve_paths(args) + if not args.dry_run: + inputs = existing_inputs(inputs, args.allow_missing) + + print("ERA5 yearly NetCDF assembly") + print(f"Year: {args.year}") + print("Inputs:") + for path in inputs: + print(f" {path}") + print(f"Output: {output_path}") + print(f"Engine: {args.engine}") + + if args.dry_run: + print("Dry run only; no file written.") + return 0 + + assemble_year(inputs, output_path, args.engine, args.overwrite) + print(f"Saved yearly NetCDF to {output_path}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) +PY diff --git a/scripts/post_processing_era5.sh b/scripts/era5/merge_prediction_shards.sh similarity index 72% rename from scripts/post_processing_era5.sh rename to scripts/era5/merge_prediction_shards.sh index c1ce559..42be8b8 100755 --- a/scripts/post_processing_era5.sh +++ b/scripts/era5/merge_prediction_shards.sh @@ -3,10 +3,10 @@ #SBATCH --mem=64G #SBATCH --cpus-per-task=4 #SBATCH --time=12:00:00 -#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/post_processing_era5.out -#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/post_processing_era5.error +#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/merge_prediction_shards.out +#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/merge_prediction_shards.error #SBATCH --open-mode=truncate -#SBATCH --job-name=post-era5 +#SBATCH --job-name=merge-shards #SBATCH --account=aip-pal set -euo pipefail @@ -21,12 +21,11 @@ usage() { } append_prediction_targets() { - local value target - IFS=',' read -r -a values <<< "$1" - for value in "${values[@]}"; do - target="${value//[[:space:]]/}" - if [[ -n "$target" ]]; then - PREDICTION_TARGETS+=("$target") + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + PREDICTION_TARGET_LIST+=("$value") fi done } @@ -35,10 +34,19 @@ POST_FORMAT="${POST_FORMAT:-csv}" SORT_OUTPUT="${SORT_OUTPUT:-1}" NUM_SHARDS="${NUM_SHARDS:-4}" NETCDF_DUPLICATE_POLICY="${NETCDF_DUPLICATE_POLICY:-error}" -PREDICTION_TARGETS=(pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE) +PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-}" +PREDICTION_TARGET_LIST=() +if [[ -n "$PREDICTION_TARGETS_ENV" ]]; then + append_prediction_targets "$PREDICTION_TARGETS_ENV" +fi while [[ $# -gt 0 ]]; do case "$1" in --format) + if [[ $# -lt 2 ]]; then + echo "--format requires a value." >&2 + usage + exit 2 + fi POST_FORMAT="$2" shift 2 ;; @@ -48,21 +56,21 @@ while [[ $# -gt 0 ]]; do ;; --prediction-targets) shift - PREDICTION_TARGETS=() + PREDICTION_TARGET_LIST=() while [[ $# -gt 0 && "$1" != --* ]]; do append_prediction_targets "$1" shift done - if [[ "${#PREDICTION_TARGETS[@]}" -eq 0 ]]; then + if [[ "${#PREDICTION_TARGET_LIST[@]}" -eq 0 ]]; then echo "--prediction-targets requires at least one target." >&2 usage exit 2 fi ;; --prediction-targets=*) - PREDICTION_TARGETS=() + PREDICTION_TARGET_LIST=() append_prediction_targets "${1#*=}" - if [[ "${#PREDICTION_TARGETS[@]}" -eq 0 ]]; then + if [[ "${#PREDICTION_TARGET_LIST[@]}" -eq 0 ]]; then echo "--prediction-targets requires at least one target." >&2 usage exit 2 @@ -78,6 +86,11 @@ while [[ $# -gt 0 ]]; do shift ;; --netcdf-duplicate-policy) + if [[ $# -lt 2 ]]; then + echo "--netcdf-duplicate-policy requires a value." >&2 + usage + exit 2 + fi NETCDF_DUPLICATE_POLICY="$2" shift 2 ;; @@ -143,6 +156,14 @@ if [[ -n "${SORT_TMP_DIR:-}" ]]; then SORT_ARGS+=(--sort-tmp-dir "$SORT_TMP_DIR") fi +PREDICTION_TARGET_ARGS=() +if [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]]; then + PREDICTION_TARGET_ARGS=(--prediction-targets "${PREDICTION_TARGET_LIST[@]}") + PREDICTION_TARGET_LABEL="${PREDICTION_TARGET_LIST[*]}" +else + PREDICTION_TARGET_LABEL="" +fi + RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" INITIAL_DATE="${INITIAL_DATE:-2017-06-01}" FINAL_DATE="${FINAL_DATE:-2017-06-30}" @@ -150,13 +171,13 @@ DATE_TAG="${INITIAL_DATE//-/}_to_${FINAL_DATE//-/}" OUTPUT_PATH="${OUTPUT_PATH:-$RUN_PATH/eval/era5_predictions_${DATE_TAG}.${OUTPUT_EXT}}" SHARD_DIR="${SHARD_DIR:-$RUN_PATH/eval/.era5_predictions_${DATE_TAG}_multi_gpu_shards}" -echo "[$(date)] Starting ERA5 post-processing job ${SLURM_JOB_ID:-local}" +echo "[$(date)] Starting prediction-shard merge job ${SLURM_JOB_ID:-local}" echo "Format: $POST_FORMAT" echo "Date window: $INITIAL_DATE to $FINAL_DATE" echo "Shard dir: $SHARD_DIR" echo "Output path: $OUTPUT_PATH" echo "Num shards: $NUM_SHARDS" -echo "Prediction targets: ${PREDICTION_TARGETS[*]}" +echo "Prediction targets: $PREDICTION_TARGET_LABEL" echo "Sort output: $SORT_LABEL" echo "NetCDF duplicate policy: $NETCDF_DUPLICATE_POLICY" echo "Temporary shard order key: __sample_order (dropped from final output when present)" @@ -168,5 +189,5 @@ python3 -u eval/merge_era5_shards.py \ --num-shards "$NUM_SHARDS" \ "${SORT_ARGS[@]}" \ --netcdf-duplicate-policy "$NETCDF_DUPLICATE_POLICY" \ - --prediction-targets "${PREDICTION_TARGETS[@]}" + "${PREDICTION_TARGET_ARGS[@]}" # --cleanup diff --git a/scripts/era5/push_year_nc.sh b/scripts/era5/push_year_nc.sh new file mode 100755 index 0000000..fec74ee --- /dev/null +++ b/scripts/era5/push_year_nc.sh @@ -0,0 +1,266 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +DEFAULT_RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" + +usage() { + cat >&2 <&2 + usage + exit 2 +} + +join_remote_dir() { + local remote="$1" + local drive_dir="$2" + + remote="${remote%/}" + drive_dir="${drive_dir#/}" + drive_dir="${drive_dir%/}" + + if [[ -z "$drive_dir" ]]; then + echo "$remote" + elif [[ "$remote" == *: ]]; then + echo "${remote}${drive_dir}" + else + echo "${remote}/${drive_dir}" + fi +} + +join_remote_file() { + local remote_dir="$1" + local filename="$2" + + if [[ "$remote_dir" == *: ]]; then + echo "${remote_dir}${filename}" + else + echo "${remote_dir%/}/${filename}" + fi +} + +YEAR="${YEAR:-}" +RUN_PATH="${RUN_PATH:-$DEFAULT_RUN_PATH}" +INPUT_DIR="${INPUT_DIR:-}" +OUTPUT_PATH="${OUTPUT_PATH:-}" +ENGINE="${XARRAY_ENGINE:-h5netcdf}" +ALLOW_MISSING=0 +OVERWRITE=0 +DRY_RUN=0 +RCLONE_REMOTE="${RCLONE_REMOTE:-gdrive:}" +DRIVE_DIR="${DRIVE_DIR:-ep_era5}" +REMOTE_DIR="${REMOTE_DIR:-}" +INPUTS=() +RCLONE_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --year) + [[ $# -ge 2 ]] || die "--year requires a value." + YEAR="$2" + shift 2 + ;; + --year=*) + YEAR="${1#*=}" + shift + ;; + --run-path) + [[ $# -ge 2 ]] || die "--run-path requires a value." + RUN_PATH="$2" + shift 2 + ;; + --run-path=*) + RUN_PATH="${1#*=}" + shift + ;; + --input-dir) + [[ $# -ge 2 ]] || die "--input-dir requires a value." + INPUT_DIR="$2" + shift 2 + ;; + --input-dir=*) + INPUT_DIR="${1#*=}" + shift + ;; + --output-path) + [[ $# -ge 2 ]] || die "--output-path requires a value." + OUTPUT_PATH="$2" + shift 2 + ;; + --output-path=*) + OUTPUT_PATH="${1#*=}" + shift + ;; + --engine) + [[ $# -ge 2 ]] || die "--engine requires a value." + ENGINE="$2" + shift 2 + ;; + --engine=*) + ENGINE="${1#*=}" + shift + ;; + --inputs) + [[ $# -ge 5 ]] || die "--inputs requires four paths." + INPUTS=("$2" "$3" "$4" "$5") + shift 5 + ;; + --allow-missing) + ALLOW_MISSING=1 + shift + ;; + --overwrite) + OVERWRITE=1 + shift + ;; + --rclone-remote) + [[ $# -ge 2 ]] || die "--rclone-remote requires a value." + RCLONE_REMOTE="$2" + shift 2 + ;; + --rclone-remote=*) + RCLONE_REMOTE="${1#*=}" + shift + ;; + --drive-dir) + [[ $# -ge 2 ]] || die "--drive-dir requires a value." + DRIVE_DIR="$2" + shift 2 + ;; + --drive-dir=*) + DRIVE_DIR="${1#*=}" + shift + ;; + --remote-dir) + [[ $# -ge 2 ]] || die "--remote-dir requires a value." + REMOTE_DIR="$2" + shift 2 + ;; + --remote-dir=*) + REMOTE_DIR="${1#*=}" + shift + ;; + --rclone-arg) + [[ $# -ge 2 ]] || die "--rclone-arg requires a value." + RCLONE_ARGS+=("$2") + shift 2 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --*) + die "Unknown argument: $1" + ;; + *) + if [[ -z "$YEAR" ]]; then + YEAR="$1" + shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +[[ -n "$YEAR" ]] || die "YEAR is required." +[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" + +if [[ -z "$INPUT_DIR" ]]; then + INPUT_DIR="$RUN_PATH/eval" +fi + +if [[ -z "$OUTPUT_PATH" ]]; then + OUTPUT_PATH="$INPUT_DIR/era5_predictions_${YEAR}.nc" +fi + +if [[ -z "$REMOTE_DIR" ]]; then + REMOTE_DIR="$(join_remote_dir "$RCLONE_REMOTE" "$DRIVE_DIR")" +fi + +REMOTE_PATH="$(join_remote_file "$REMOTE_DIR" "$(basename "$OUTPUT_PATH")")" + +ASSEMBLE_ARGS=( + --year "$YEAR" + --run-path "$RUN_PATH" + --input-dir "$INPUT_DIR" + --output-path "$OUTPUT_PATH" + --engine "$ENGINE" +) + +if [[ "${#INPUTS[@]}" -gt 0 ]]; then + ASSEMBLE_ARGS+=(--inputs "${INPUTS[@]}") +fi +if [[ "$ALLOW_MISSING" == "1" ]]; then + ASSEMBLE_ARGS+=(--allow-missing) +fi +if [[ "$OVERWRITE" == "1" ]]; then + ASSEMBLE_ARGS+=(--overwrite) +fi +if [[ "$DRY_RUN" == "1" ]]; then + ASSEMBLE_ARGS+=(--dry-run) +fi + +echo "[$(date)] Assembling yearly ERA5 NetCDF." +echo "Year: $YEAR" +echo "Output path: $OUTPUT_PATH" +"$SCRIPT_DIR/build_year_nc.sh" "${ASSEMBLE_ARGS[@]}" + +if [[ "$DRY_RUN" == "1" ]]; then + echo "[$(date)] Dry-run upload target: $REMOTE_PATH" + printf 'Dry-run upload command: rclone copyto %q %q --progress' "$OUTPUT_PATH" "$REMOTE_PATH" + if [[ "${#RCLONE_ARGS[@]}" -gt 0 ]]; then + printf ' %q' "${RCLONE_ARGS[@]}" + fi + printf '\n' + exit 0 +fi + +if [[ ! -f "$OUTPUT_PATH" ]]; then + echo "Assembled output not found: $OUTPUT_PATH" >&2 + exit 1 +fi + +echo "[$(date)] Uploading yearly ERA5 NetCDF with rclone." +echo "Destination: $REMOTE_PATH" +rclone mkdir "$REMOTE_DIR" +rclone copyto "$OUTPUT_PATH" "$REMOTE_PATH" --progress "${RCLONE_ARGS[@]}" +rclone lsf "$REMOTE_PATH" +echo "[$(date)] Upload complete: $REMOTE_PATH" diff --git a/scripts/run_era5.sh b/scripts/era5/run.sh old mode 100644 new mode 100755 similarity index 74% rename from scripts/run_era5.sh rename to scripts/era5/run.sh index bfce15d..a708bdc --- a/scripts/run_era5.sh +++ b/scripts/era5/run.sh @@ -4,10 +4,10 @@ #SBATCH --mem=64G #SBATCH --cpus-per-task=32 #SBATCH --time=1:00:00 -#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/eval_era5_single.out -#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/eval_era5_single.error +#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/run.out +#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/run.error #SBATCH --open-mode=truncate -#SBATCH --job-name=eval-era5 +#SBATCH --job-name=run #SBATCH --account=aip-pal set -euo pipefail @@ -16,9 +16,9 @@ source $SCRATCH/env/ecoperceiver/bin/activate cd ~/links/scratch/EcoPerceiver export PYTHONUNBUFFERED=1 -echo "[$(date)] Starting eval-era5 job ${SLURM_JOB_ID:-local} on ${SLURM_JOB_NODELIST:-local}" -echo "stdout: /scratch/l/luislara/EcoPerceiver/logs/eval_era5_single.out" -echo "stderr: /scratch/l/luislara/EcoPerceiver/logs/eval_era5_single.error" +echo "[$(date)] Starting single-process inference job ${SLURM_JOB_ID:-local} on ${SLURM_JOB_NODELIST:-local}" +echo "stdout: /scratch/l/luislara/EcoPerceiver/logs/run.out" +echo "stderr: /scratch/l/luislara/EcoPerceiver/logs/run.error" RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" DB_PATH="/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db" diff --git a/scripts/run_era5_multi_gpu.sh b/scripts/era5/run_multi_gpu.sh similarity index 57% rename from scripts/run_era5_multi_gpu.sh rename to scripts/era5/run_multi_gpu.sh index 7dd0310..f77ddaa 100755 --- a/scripts/run_era5_multi_gpu.sh +++ b/scripts/era5/run_multi_gpu.sh @@ -4,10 +4,10 @@ #SBATCH --mem=256G #SBATCH --cpus-per-task=48 #SBATCH --time=23:59:00 -#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/eval_era5_multi_gpu.out -#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/eval_era5_multi_gpu.error +#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.out +#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.error #SBATCH --open-mode=truncate -#SBATCH --job-name=eval-era5-mgpu +#SBATCH --job-name=run-mgpu #SBATCH --account=aip-pal set -euo pipefail @@ -18,23 +18,69 @@ cd ~/links/scratch/EcoPerceiver export PYTHONUNBUFFERED=1 export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" -GPU_LOG_OUT="${GPU_LOG_OUT:-/scratch/l/luislara/EcoPerceiver/logs/eval_era5_multi_gpu.out}" -GPU_LOG_ERR="${GPU_LOG_ERR:-/scratch/l/luislara/EcoPerceiver/logs/eval_era5_multi_gpu.error}" +append_prediction_targets() { + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + PREDICTION_TARGET_LIST+=("$value") + fi + done +} -echo "[$(date)] Starting eval-era5 multi-GPU job ${SLURM_JOB_ID:-local} on ${SLURM_JOB_NODELIST:-local}" +GPU_LOG_OUT="${GPU_LOG_OUT:-/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.out}" +GPU_LOG_ERR="${GPU_LOG_ERR:-/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.error}" + +echo "[$(date)] Starting date-range multi-GPU inference job ${SLURM_JOB_ID:-local} on ${SLURM_JOB_NODELIST:-local}" echo "stdout: $GPU_LOG_OUT" echo "stderr: $GPU_LOG_ERR" RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" -DB_PATH="${DB_PATH:-/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db}" +CHECKPOINT_PATH="${CHECKPOINT_PATH:-checkpoint-11.pth}" INITIAL_DATE="${INITIAL_DATE:-2017-06-01}" FINAL_DATE="${FINAL_DATE:-2017-06-30}" +DATA_ROOT="${DATA_ROOT:-/home/l/luislara/links/projects/aip-pal/luislara/ep/data}" +DB_PATH="${DB_PATH:-}" +DB_START_YEAR="${DB_START_YEAR:-}" +DB_LABEL="${DB_LABEL:-}" +if [[ -z "$DB_PATH" ]]; then + initial_year="${INITIAL_DATE:0:4}" + if [[ ! "$initial_year" =~ ^[0-9]{4}$ ]]; then + echo "Cannot infer DB_PATH from INITIAL_DATE=$INITIAL_DATE; set DB_PATH explicitly." >&2 + exit 2 + fi + if [[ -z "$DB_LABEL" ]]; then + if [[ -n "$DB_START_YEAR" ]]; then + if [[ ! "$DB_START_YEAR" =~ ^[0-9]{4}$ ]]; then + echo "DB_START_YEAR must be a four-digit year, got: $DB_START_YEAR" >&2 + exit 2 + fi + db_start_number=$((10#$DB_START_YEAR)) + else + initial_year_number=$((10#$initial_year)) + if (( initial_year_number % 2 == 0 )); then + db_start_number="$initial_year_number" + else + db_start_number=$((initial_year_number - 1)) + fi + fi + DB_LABEL="${db_start_number}_$((db_start_number + 1))" + fi + DB_PATH="${DATA_ROOT}/${DB_LABEL}/era5_${DB_LABEL}.db" +fi DATE_TAG="${INITIAL_DATE//-/}_to_${FINAL_DATE//-/}" OUTPUT_CSV="${OUTPUT_CSV:-$RUN_PATH/eval/era5_predictions_${DATE_TAG}.csv}" SHARD_DIR="${SHARD_DIR:-$RUN_PATH/eval/.era5_predictions_${DATE_TAG}_multi_gpu_shards}" LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" IGBP_EXCLUDED=(WAT SNO BSV URB CRO CVM) -PREDICTION_TARGETS=(pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE) +PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" +PREDICTION_TARGET_LIST=(pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE) +if [[ -n "$PREDICTION_TARGETS_ENV" ]]; then + PREDICTION_TARGET_LIST=() + append_prediction_targets "$PREDICTION_TARGETS_ENV" +fi +PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" +export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" BATCH_SIZE_PER_GPU="${BATCH_SIZE_PER_GPU:-32768}" NUM_WORKERS_PER_GPU="${NUM_WORKERS_PER_GPU:-12}" @@ -57,7 +103,14 @@ case "${DATALOADER_IN_ORDER,,}" in ;; esac +if [[ ! -f "$DB_PATH" ]]; then + echo "ERA5 DB not found: $DB_PATH" >&2 + exit 2 +fi + echo "Date window: $INITIAL_DATE to $FINAL_DATE" +echo "DB path: $DB_PATH" +echo "Checkpoint path: $CHECKPOINT_PATH" echo "Output CSV: $OUTPUT_CSV" echo "Shard dir: $SHARD_DIR" echo "GPUs: 4" @@ -65,13 +118,14 @@ echo "Batch size per GPU: $BATCH_SIZE_PER_GPU" echo "Dataloader workers per GPU: $NUM_WORKERS_PER_GPU" echo "Prefetch factor: $PREFETCH_FACTOR" echo "Dataloader in-order delivery: $DATALOADER_IN_ORDER_LABEL" +echo "Prediction targets: $PREDICTION_TARGETS_VALUE" echo "Temporary shard order key: __sample_order (dropped during post-processing)" echo "Distributed timeout minutes: $DIST_TIMEOUT_MINUTES" torchrun --standalone --nnodes=1 --nproc-per-node=4 \ eval/test_era5_multi_gpu.py \ --run-path "$RUN_PATH" \ - --checkpoint-path checkpoint-11.pth \ + --checkpoint-path "$CHECKPOINT_PATH" \ --db-path "$DB_PATH" \ --initial-date "$INITIAL_DATE" \ --final-date "$FINAL_DATE" \ @@ -83,12 +137,12 @@ torchrun --standalone --nnodes=1 --nproc-per-node=4 \ --prefetch-factor "$PREFETCH_FACTOR" \ "${DATALOADER_ORDER_ARGS[@]}" \ --exclude-igbp "${IGBP_EXCLUDED[@]}" \ - --prediction-targets "${PREDICTION_TARGETS[@]}" \ + --prediction-targets "${PREDICTION_TARGET_LIST[@]}" \ --gpp-solar-threshold 2.0 \ --skip-merge # --max-samples 1000000 \ -echo "[$(date)] GPU inference shards complete. Submitting scripts/post_processing_era5.sh on CPU." +echo "[$(date)] GPU inference shards complete. Submitting scripts/era5/merge_prediction_shards.sh on CPU." POST_FORMAT="${POST_FORMAT:-csv}" case "$POST_FORMAT" in csv) @@ -106,10 +160,10 @@ esac mkdir -p "$LOG_DIR" POST_JOB_ID="$( sbatch --parsable \ - --job-name "post-era5-${DATE_TAG}" \ - --output "$LOG_DIR/post_processing_era5_${DATE_TAG}.out" \ - --error "$LOG_DIR/post_processing_era5_${DATE_TAG}.error" \ + --job-name "merge-shards-${DATE_TAG}" \ + --output "$LOG_DIR/merge_prediction_shards_${DATE_TAG}.out" \ + --error "$LOG_DIR/merge_prediction_shards_${DATE_TAG}.error" \ --export=ALL,RUN_PATH="$RUN_PATH",INITIAL_DATE="$INITIAL_DATE",FINAL_DATE="$FINAL_DATE",POST_FORMAT="$POST_FORMAT",SHARD_DIR="$SHARD_DIR",OUTPUT_PATH="$POST_OUTPUT_PATH" \ - scripts/post_processing_era5.sh + scripts/era5/merge_prediction_shards.sh )" echo "[$(date)] Submitted ERA5 CPU post-processing job: $POST_JOB_ID (format: $POST_FORMAT, output: $POST_OUTPUT_PATH)" diff --git a/scripts/era5/submit_year.sh b/scripts/era5/submit_year.sh new file mode 100755 index 0000000..e86a7c4 --- /dev/null +++ b/scripts/era5/submit_year.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat >&2 < /2016_2017/era5_2016_2017.db + 2017 -> /2016_2017/era5_2016_2017.db + +Options: + --year YEAR Year to infer. Positional YEAR is also accepted. + --db-path PATH Explicit ERA5 SQLite database path. + --db-start-year YEAR First year in the two-year database. + --db-label LABEL Database label, for example 2016_2017. + --data-root PATH Root directory containing date-range DB folders. + --run-path PATH EcoPerceiver run directory. + --checkpoint-path PATH Checkpoint path relative to run-path, or absolute. + --prediction-targets LIST Prediction targets, comma or space separated. + --parallel Submit all quarters immediately. Default. + --sequential Chain quarter jobs with afterok dependencies. + --post-format csv|netcdf Final post-processing output format. Default: netcdf. + --dry-run Print sbatch commands without submitting. + --no-check-db Do not require the DB path to exist before submit. +EOF +} + +die() { + echo "$1" >&2 + usage + exit 2 +} + +append_prediction_targets() { + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + PREDICTION_TARGET_LIST+=("$value") + fi + done +} + +YEAR="${YEAR:-}" +MODE="${MODE:-parallel}" +POST_FORMAT="${POST_FORMAT:-netcdf}" +DRY_RUN="${DRY_RUN:-0}" +CHECK_DB="${CHECK_DB:-1}" +RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" +CHECKPOINT_PATH="${CHECKPOINT_PATH:-checkpoint-11.pth}" +LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" +DATA_ROOT="${DATA_ROOT:-/home/l/luislara/links/projects/aip-pal/luislara/ep/data}" +DB_PATH="${DB_PATH:-}" +DB_START_YEAR="${DB_START_YEAR:-}" +DB_LABEL="${DB_LABEL:-}" +PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" +PREDICTION_TARGET_LIST=() +append_prediction_targets "$PREDICTION_TARGETS_ENV" + +while [[ $# -gt 0 ]]; do + case "$1" in + --year) + [[ $# -ge 2 ]] || die "--year requires a value." + YEAR="$2" + shift 2 + ;; + --year=*) + YEAR="${1#*=}" + shift + ;; + --db-path) + [[ $# -ge 2 ]] || die "--db-path requires a value." + DB_PATH="$2" + shift 2 + ;; + --db-path=*) + DB_PATH="${1#*=}" + shift + ;; + --db-start-year) + [[ $# -ge 2 ]] || die "--db-start-year requires a value." + DB_START_YEAR="$2" + shift 2 + ;; + --db-start-year=*) + DB_START_YEAR="${1#*=}" + shift + ;; + --db-label) + [[ $# -ge 2 ]] || die "--db-label requires a value." + DB_LABEL="$2" + shift 2 + ;; + --db-label=*) + DB_LABEL="${1#*=}" + shift + ;; + --data-root) + [[ $# -ge 2 ]] || die "--data-root requires a value." + DATA_ROOT="$2" + shift 2 + ;; + --data-root=*) + DATA_ROOT="${1#*=}" + shift + ;; + --run-path) + [[ $# -ge 2 ]] || die "--run-path requires a value." + RUN_PATH="$2" + shift 2 + ;; + --run-path=*) + RUN_PATH="${1#*=}" + shift + ;; + --checkpoint-path) + [[ $# -ge 2 ]] || die "--checkpoint-path requires a value." + CHECKPOINT_PATH="$2" + shift 2 + ;; + --checkpoint-path=*) + CHECKPOINT_PATH="${1#*=}" + shift + ;; + --prediction-targets) + shift + PREDICTION_TARGET_LIST=() + while [[ $# -gt 0 && "$1" != --* ]]; do + append_prediction_targets "$1" + shift + done + [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." + ;; + --prediction-targets=*) + PREDICTION_TARGET_LIST=() + append_prediction_targets "${1#*=}" + [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." + shift + ;; + --parallel) + MODE="parallel" + shift + ;; + --sequential) + MODE="sequential" + shift + ;; + --post-format) + [[ $# -ge 2 ]] || die "--post-format requires a value." + POST_FORMAT="$2" + shift 2 + ;; + --post-format=*) + POST_FORMAT="${1#*=}" + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --no-check-db) + CHECK_DB=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -z "$YEAR" ]]; then + YEAR="$1" + shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +[[ -n "$YEAR" ]] || die "YEAR is required." +[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" + +case "$MODE" in + parallel|sequential) + ;; + *) + die "MODE must be parallel or sequential, got: $MODE" + ;; +esac + +case "$POST_FORMAT" in + csv|netcdf) + ;; + *) + die "POST_FORMAT must be csv or netcdf, got: $POST_FORMAT" + ;; +esac + +year_number=$((10#$YEAR)) +if [[ -n "$DB_START_YEAR" ]]; then + [[ "$DB_START_YEAR" =~ ^[0-9]{4}$ ]] || die "DB_START_YEAR must be a four-digit year, got: $DB_START_YEAR" + db_start_number=$((10#$DB_START_YEAR)) +else + if (( year_number % 2 == 0 )); then + db_start_number="$year_number" + else + db_start_number=$((year_number - 1)) + fi +fi + +if [[ -z "$DB_LABEL" ]]; then + db_end_number=$((db_start_number + 1)) + DB_LABEL="${db_start_number}_${db_end_number}" +fi + +if [[ -z "$DB_PATH" ]]; then + DB_PATH="${DATA_ROOT}/${DB_LABEL}/era5_${DB_LABEL}.db" +fi + +if [[ "$CHECK_DB" != "0" && ! -f "$DB_PATH" ]]; then + if [[ "$DRY_RUN" == "1" ]]; then + echo "WARNING: ERA5 DB not found: $DB_PATH" >&2 + else + echo "ERA5 DB not found: $DB_PATH" >&2 + echo "Use --db-path, --db-start-year, --db-label, or --data-root to point at the two-year DB." >&2 + exit 2 + fi +fi + +mkdir -p "$LOG_DIR" + +starts=("${YEAR}-01-01" "${YEAR}-04-01" "${YEAR}-07-01" "${YEAR}-10-01") +ends=("${YEAR}-03-31" "${YEAR}-06-30" "${YEAR}-09-30" "${YEAR}-12-31") + +PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" +export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" +export CHECKPOINT_PATH + +previous_dependency_id="" +echo "Submitting ERA5 one-year inference for $YEAR in $MODE mode." +echo "Two-year DB label: $DB_LABEL" +echo "DB path: $DB_PATH" +echo "Run path: $RUN_PATH" +echo "Checkpoint path: $CHECKPOINT_PATH" +echo "Post format: $POST_FORMAT" +echo "Prediction targets: $PREDICTION_TARGETS_VALUE" + +for chunk_index in "${!starts[@]}"; do + initial_date="${starts[$chunk_index]}" + final_date="${ends[$chunk_index]}" + date_tag="${initial_date//-/}_to_${final_date//-/}" + job_name="run-mgpu-${YEAR}-q$((chunk_index + 1))" + log_base="$LOG_DIR/run_multi_gpu_${date_tag}" + log_out="${log_base}.out" + log_err="${log_base}.error" + output_csv="$RUN_PATH/eval/era5_predictions_${date_tag}.csv" + shard_dir="$RUN_PATH/eval/.era5_predictions_${date_tag}_multi_gpu_shards" + + sbatch_args=( + --parsable + --job-name "$job_name" + --output "$log_out" + --error "$log_err" + --export=ALL,RUN_PATH="$RUN_PATH",DB_PATH="$DB_PATH",INITIAL_DATE="$initial_date",FINAL_DATE="$final_date",POST_FORMAT="$POST_FORMAT",LOG_DIR="$LOG_DIR",GPU_LOG_OUT="$log_out",GPU_LOG_ERR="$log_err",OUTPUT_CSV="$output_csv",SHARD_DIR="$shard_dir",OUTPUT_PATH= + ) + + if [[ "$MODE" == "sequential" && -n "$previous_dependency_id" ]]; then + sbatch_args+=(--dependency="afterok:$previous_dependency_id") + fi + + if [[ "$DRY_RUN" == "1" ]]; then + printf 'DRY RUN: PREDICTION_TARGETS=%q CHECKPOINT_PATH=%q sbatch' "$PREDICTION_TARGETS" "$CHECKPOINT_PATH" + printf ' %q' "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh + printf '\n' + job_id="dryrun-$((chunk_index + 1))" + else + job_id="$(sbatch "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh)" + fi + + dependency_id="${job_id%%;*}" + previous_dependency_id="$dependency_id" + echo "Chunk $((chunk_index + 1)): $initial_date to $final_date -> $job_id" +done diff --git a/scripts/run_era5_one_year_inference.sh b/scripts/run_era5_one_year_inference.sh deleted file mode 100755 index 5c60d89..0000000 --- a/scripts/run_era5_one_year_inference.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -usage() { - echo "Usage: $0 YEAR [--parallel|--sequential] [--post-format csv|netcdf] [--dry-run]" >&2 - echo "Example: $0 2017" >&2 -} - -YEAR="${YEAR:-2017}" -MODE="${MODE:-parallel}" -POST_FORMAT="${POST_FORMAT:-netcdf}" -DRY_RUN="${DRY_RUN:-0}" -RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" -LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --year) - YEAR="$2" - shift 2 - ;; - --year=*) - YEAR="${1#*=}" - shift - ;; - --parallel) - MODE="parallel" - shift - ;; - --sequential) - MODE="sequential" - shift - ;; - --post-format) - POST_FORMAT="$2" - shift 2 - ;; - --post-format=*) - POST_FORMAT="${1#*=}" - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - if [[ -z "$YEAR" ]]; then - YEAR="$1" - shift - else - echo "Unknown argument: $1" >&2 - usage - exit 2 - fi - ;; - esac -done - -YEAR="${YEAR:-2017}" - -if [[ ! "$YEAR" =~ ^[0-9]{4}$ ]]; then - echo "YEAR must be a four-digit year, got: ${YEAR:-}" >&2 - usage - exit 2 -fi - -case "$MODE" in - parallel|sequential) - ;; - *) - echo "MODE must be parallel or sequential, got: $MODE" >&2 - exit 2 - ;; -esac - -case "$POST_FORMAT" in - csv|netcdf) - ;; - *) - echo "POST_FORMAT must be csv or netcdf, got: $POST_FORMAT" >&2 - exit 2 - ;; -esac - -mkdir -p "$LOG_DIR" - -starts=("${YEAR}-01-01" "${YEAR}-04-01" "${YEAR}-07-01" "${YEAR}-10-01") -ends=("${YEAR}-03-31" "${YEAR}-06-30" "${YEAR}-09-30" "${YEAR}-12-31") - -previous_dependency_id="" -echo "Submitting ERA5 one-year inference for $YEAR in $MODE mode." - -for chunk_index in "${!starts[@]}"; do - initial_date="${starts[$chunk_index]}" - final_date="${ends[$chunk_index]}" - date_tag="${initial_date//-/}_to_${final_date//-/}" - job_name="eval-era5-${YEAR}-$((chunk_index + 1))" - log_base="$LOG_DIR/eval_era5_multi_gpu_${date_tag}" - log_out="${log_base}.out" - log_err="${log_base}.error" - output_csv="$RUN_PATH/eval/era5_predictions_${date_tag}.csv" - shard_dir="$RUN_PATH/eval/.era5_predictions_${date_tag}_multi_gpu_shards" - - sbatch_args=( - --parsable - --job-name "$job_name" - --output "$log_out" - --error "$log_err" - --export=ALL,RUN_PATH="$RUN_PATH",INITIAL_DATE="$initial_date",FINAL_DATE="$final_date",POST_FORMAT="$POST_FORMAT",LOG_DIR="$LOG_DIR",GPU_LOG_OUT="$log_out",GPU_LOG_ERR="$log_err",OUTPUT_CSV="$output_csv",SHARD_DIR="$shard_dir",OUTPUT_PATH= - ) - - if [[ "$MODE" == "sequential" && -n "$previous_dependency_id" ]]; then - sbatch_args+=(--dependency="afterok:$previous_dependency_id") - fi - - if [[ "$DRY_RUN" == "1" ]]; then - printf 'DRY RUN: sbatch' - printf ' %q' "${sbatch_args[@]}" scripts/run_era5_multi_gpu.sh - printf '\n' - job_id="dryrun-$((chunk_index + 1))" - else - job_id="$(sbatch "${sbatch_args[@]}" scripts/run_era5_multi_gpu.sh)" - fi - - dependency_id="${job_id%%;*}" - previous_dependency_id="$dependency_id" - echo "Chunk $((chunk_index + 1)): $initial_date to $final_date -> $job_id" -done From 231604c13dcddddf1877dcfd478e4254dbcbda64 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Mon, 29 Jun 2026 16:19:04 -0400 Subject: [PATCH 11/14] inference working --- eval/era5/__init__.py | 1 + eval/{ => era5}/era5_db_launch.py | 4 +- eval/{ => era5}/merge_era5_shards.py | 2 +- eval/{ => era5}/test_era5.py | 10 +- eval/{ => era5}/test_era5_multi_gpu.py | 17 +- scripts/era5/merge_prediction_shards.sh | 10 +- ...{push_year_nc.sh => push_year_to_drive.sh} | 0 scripts/era5/push_year_to_hf.sh | 317 ++++++++++++++++++ scripts/era5/run.sh | 2 +- scripts/era5/run_multi_gpu.sh | 20 +- scripts/era5/submit_year.sh | 71 +++- 11 files changed, 433 insertions(+), 21 deletions(-) create mode 100644 eval/era5/__init__.py rename eval/{ => era5}/era5_db_launch.py (98%) rename eval/{ => era5}/merge_era5_shards.py (99%) rename eval/{ => era5}/test_era5.py (98%) rename eval/{ => era5}/test_era5_multi_gpu.py (98%) rename scripts/era5/{push_year_nc.sh => push_year_to_drive.sh} (100%) create mode 100755 scripts/era5/push_year_to_hf.sh diff --git a/eval/era5/__init__.py b/eval/era5/__init__.py new file mode 100644 index 0000000..387c272 --- /dev/null +++ b/eval/era5/__init__.py @@ -0,0 +1 @@ +"""ERA5 evaluation and post-processing entrypoints.""" diff --git a/eval/era5_db_launch.py b/eval/era5/era5_db_launch.py similarity index 98% rename from eval/era5_db_launch.py rename to eval/era5/era5_db_launch.py index 11facd1..2463a12 100644 --- a/eval/era5_db_launch.py +++ b/eval/era5/era5_db_launch.py @@ -21,8 +21,8 @@ # ======================== Paths and Table Names ======================== -BASE_DIR = Path(__file__).resolve().parent -DATA_PATH = BASE_DIR.parent / 'experiments/data' +REPO_ROOT = Path(__file__).resolve().parents[2] +DATA_PATH = REPO_ROOT / 'experiments/data' ERA5_DATA_PATH = DATA_PATH / 'era5_data' DB_SCHEMA_PATH = DATA_PATH / 'carbonpipeline_db_struct.sql' IGBP_PATH = DATA_PATH / 'igbp.tiff' diff --git a/eval/merge_era5_shards.py b/eval/era5/merge_era5_shards.py similarity index 99% rename from eval/merge_era5_shards.py rename to eval/era5/merge_era5_shards.py index 43bd6f4..5f0213c 100644 --- a/eval/merge_era5_shards.py +++ b/eval/era5/merge_era5_shards.py @@ -512,7 +512,7 @@ def build_era5_cube_dataset(df, output_columns, num_shards, duplicate_policy, np dataset.attrs["source"] = "EcoPerceiver ERA5 torchrun inference shards" dataset.attrs["history"] = ( f"{datetime.now(timezone.utc).isoformat()} EcoPerceiver predictions " - "converted to ERA5-like NetCDF via eval/merge_era5_shards.py" + "converted to ERA5-like NetCDF via eval/era5/merge_era5_shards.py" ) dataset.attrs["num_shards"] = num_shards dataset.attrs["num_input_rows"] = input_row_count diff --git a/eval/test_era5.py b/eval/era5/test_era5.py similarity index 98% rename from eval/test_era5.py rename to eval/era5/test_era5.py index c4c54eb..fa75459 100644 --- a/eval/test_era5.py +++ b/eval/era5/test_era5.py @@ -1,9 +1,15 @@ import argparse import csv import math +import sys from datetime import datetime, time from pathlib import Path -from utils import resolve_checkpoint_path, resolve_config_path, resolve_device + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from eval.utils import resolve_checkpoint_path, resolve_config_path, resolve_device DATE_ONLY_FORMATS = ("%Y-%m-%d", "%Y%m%d") DATETIME_FORMATS = ( @@ -296,7 +302,7 @@ def main(): from ecoperceiver.components import EcoPerceiverConfig from ecoperceiver.era5_model import ERA5EcoPerceiver - repo_root = Path(__file__).resolve().parent.parent + repo_root = REPO_ROOT run_path = args.run_path.resolve() config_path = resolve_config_path(run_path, args.config_path) explicit_checkpoint_path = args.checkpoint_path.expanduser() if args.checkpoint_path is not None else None diff --git a/eval/test_era5_multi_gpu.py b/eval/era5/test_era5_multi_gpu.py similarity index 98% rename from eval/test_era5_multi_gpu.py rename to eval/era5/test_era5_multi_gpu.py index 9388b88..cd726d7 100644 --- a/eval/test_era5_multi_gpu.py +++ b/eval/era5/test_era5_multi_gpu.py @@ -3,17 +3,22 @@ import math import os import shutil +import sys from datetime import timedelta from pathlib import Path -from test_era5 import ( +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from eval.era5.test_era5 import ( build_date_filter, normalize_predictor_value, parse_requested_prediction_targets, resolve_prediction_target_indices, zero_low_solar_gpp_predictions, ) -from utils import resolve_checkpoint_path, resolve_config_path +from eval.utils import resolve_checkpoint_path, resolve_config_path INTERNAL_ORDER_COLUMN = "__sample_order" @@ -93,8 +98,8 @@ def parse_args(): "--skip-merge", action="store_true", help=( - "Only write per-rank shard CSVs. Use eval/merge_era5_shards.py " - "or scripts/post_era5.sh to merge them later." + "Only write per-rank shard CSVs. Use eval/era5/merge_era5_shards.py " + "or scripts/era5/merge_prediction_shards.sh to merge them later." ), ) parser.add_argument( @@ -359,7 +364,7 @@ def prepare_shard_dir(shard_dir: Path, *, rank: int, distributed: bool, device): def merge_csv_shards(shard_paths: list[Path], output_csv_path: Path): - from merge_era5_shards import merge_csv_shards as merge_post_csv_shards + from eval.era5.merge_era5_shards import merge_csv_shards as merge_post_csv_shards merge_post_csv_shards( shard_paths, @@ -442,7 +447,7 @@ def main(): from ecoperceiver.era5_dataset import ERA5Dataset from ecoperceiver.era5_model import ERA5EcoPerceiver - repo_root = Path(__file__).resolve().parent.parent + repo_root = REPO_ROOT run_path = args.run_path.resolve() config_path = resolve_config_path(run_path, args.config_path) explicit_checkpoint_path = args.checkpoint_path.expanduser() if args.checkpoint_path is not None else None diff --git a/scripts/era5/merge_prediction_shards.sh b/scripts/era5/merge_prediction_shards.sh index 42be8b8..d3c85e7 100755 --- a/scripts/era5/merge_prediction_shards.sh +++ b/scripts/era5/merge_prediction_shards.sh @@ -1,6 +1,6 @@ #!/bin/bash #SBATCH --nodes=1 -#SBATCH --mem=64G +#SBATCH --mem=256G #SBATCH --cpus-per-task=4 #SBATCH --time=12:00:00 #SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/merge_prediction_shards.out @@ -30,11 +30,11 @@ append_prediction_targets() { done } -POST_FORMAT="${POST_FORMAT:-csv}" +POST_FORMAT="${POST_FORMAT:-netcdf}" SORT_OUTPUT="${SORT_OUTPUT:-1}" NUM_SHARDS="${NUM_SHARDS:-4}" -NETCDF_DUPLICATE_POLICY="${NETCDF_DUPLICATE_POLICY:-error}" -PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-}" +NETCDF_DUPLICATE_POLICY="${NETCDF_DUPLICATE_POLICY:-last}" +PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" PREDICTION_TARGET_LIST=() if [[ -n "$PREDICTION_TARGETS_ENV" ]]; then append_prediction_targets "$PREDICTION_TARGETS_ENV" @@ -182,7 +182,7 @@ echo "Sort output: $SORT_LABEL" echo "NetCDF duplicate policy: $NETCDF_DUPLICATE_POLICY" echo "Temporary shard order key: __sample_order (dropped from final output when present)" -python3 -u eval/merge_era5_shards.py \ +python3 -u eval/era5/merge_era5_shards.py \ --format "$POST_FORMAT" \ --shard-dir "$SHARD_DIR" \ --output-path "$OUTPUT_PATH" \ diff --git a/scripts/era5/push_year_nc.sh b/scripts/era5/push_year_to_drive.sh similarity index 100% rename from scripts/era5/push_year_nc.sh rename to scripts/era5/push_year_to_drive.sh diff --git a/scripts/era5/push_year_to_hf.sh b/scripts/era5/push_year_to_hf.sh new file mode 100755 index 0000000..5e13102 --- /dev/null +++ b/scripts/era5/push_year_to_hf.sh @@ -0,0 +1,317 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +DEFAULT_RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" +DEFAULT_HF_REPO_ID="ludolara/era5" +PYTHON_BIN="${PYTHON_BIN:-}" + +if [[ -f "$REPO_ROOT/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$REPO_ROOT/.env" + set +a +fi + +usage() { + cat >&2 <&2 + usage + exit 2 +} + +YEAR="${YEAR:-}" +RUN_PATH="${RUN_PATH:-$DEFAULT_RUN_PATH}" +INPUT_DIR="${INPUT_DIR:-}" +OUTPUT_PATH="${OUTPUT_PATH:-}" +ENGINE="${XARRAY_ENGINE:-h5netcdf}" +ALLOW_MISSING=0 +OVERWRITE=0 +DRY_RUN=0 +HF_REPO_ID="${HF_REPO_ID:-$DEFAULT_HF_REPO_ID}" +PATH_IN_REPO="${PATH_IN_REPO:-}" +REVISION="${HF_REVISION:-main}" +COMMIT_MESSAGE="${HF_COMMIT_MESSAGE:-}" +CREATE_REPO=1 +PRIVATE=0 +INPUTS=() + +if [[ -z "$PYTHON_BIN" ]]; then + if [[ -n "${ECOPERCEIVER_ENV:-}" && -x "$ECOPERCEIVER_ENV/bin/python" ]]; then + PYTHON_BIN="$ECOPERCEIVER_ENV/bin/python" + elif [[ -n "${SCRATCH:-}" && -x "$SCRATCH/env/ecoperceiver/bin/python" ]]; then + PYTHON_BIN="$SCRATCH/env/ecoperceiver/bin/python" + else + PYTHON_BIN="python3" + fi +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --year) + [[ $# -ge 2 ]] || die "--year requires a value." + YEAR="$2" + shift 2 + ;; + --year=*) + YEAR="${1#*=}" + shift + ;; + --run-path) + [[ $# -ge 2 ]] || die "--run-path requires a value." + RUN_PATH="$2" + shift 2 + ;; + --run-path=*) + RUN_PATH="${1#*=}" + shift + ;; + --input-dir) + [[ $# -ge 2 ]] || die "--input-dir requires a value." + INPUT_DIR="$2" + shift 2 + ;; + --input-dir=*) + INPUT_DIR="${1#*=}" + shift + ;; + --output-path) + [[ $# -ge 2 ]] || die "--output-path requires a value." + OUTPUT_PATH="$2" + shift 2 + ;; + --output-path=*) + OUTPUT_PATH="${1#*=}" + shift + ;; + --engine) + [[ $# -ge 2 ]] || die "--engine requires a value." + ENGINE="$2" + shift 2 + ;; + --engine=*) + ENGINE="${1#*=}" + shift + ;; + --inputs) + [[ $# -ge 5 ]] || die "--inputs requires four paths." + INPUTS=("$2" "$3" "$4" "$5") + shift 5 + ;; + --allow-missing) + ALLOW_MISSING=1 + shift + ;; + --overwrite) + OVERWRITE=1 + shift + ;; + --hf-repo-id) + [[ $# -ge 2 ]] || die "--hf-repo-id requires a value." + HF_REPO_ID="$2" + shift 2 + ;; + --hf-repo-id=*) + HF_REPO_ID="${1#*=}" + shift + ;; + --path-in-repo) + [[ $# -ge 2 ]] || die "--path-in-repo requires a value." + PATH_IN_REPO="$2" + shift 2 + ;; + --path-in-repo=*) + PATH_IN_REPO="${1#*=}" + shift + ;; + --revision) + [[ $# -ge 2 ]] || die "--revision requires a value." + REVISION="$2" + shift 2 + ;; + --revision=*) + REVISION="${1#*=}" + shift + ;; + --commit-message) + [[ $# -ge 2 ]] || die "--commit-message requires a value." + COMMIT_MESSAGE="$2" + shift 2 + ;; + --commit-message=*) + COMMIT_MESSAGE="${1#*=}" + shift + ;; + --private) + PRIVATE=1 + shift + ;; + --skip-create-repo) + CREATE_REPO=0 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --*) + die "Unknown argument: $1" + ;; + *) + if [[ -z "$YEAR" ]]; then + YEAR="$1" + shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +[[ -n "$YEAR" ]] || die "YEAR is required." +[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" +[[ -n "$HF_REPO_ID" ]] || die "HF repo id is required." + +if [[ -z "$INPUT_DIR" ]]; then + INPUT_DIR="$RUN_PATH/eval" +fi + +if [[ -z "$OUTPUT_PATH" ]]; then + OUTPUT_PATH="$INPUT_DIR/era5_predictions_${YEAR}.nc" +fi + +if [[ -z "$PATH_IN_REPO" ]]; then + PATH_IN_REPO="$(basename "$OUTPUT_PATH")" +fi +PATH_IN_REPO="${PATH_IN_REPO#/}" + +if [[ -z "$COMMIT_MESSAGE" ]]; then + COMMIT_MESSAGE="Upload ERA5 predictions for ${YEAR}" +fi + +ASSEMBLE_ARGS=( + --year "$YEAR" + --run-path "$RUN_PATH" + --input-dir "$INPUT_DIR" + --output-path "$OUTPUT_PATH" + --engine "$ENGINE" +) + +if [[ "${#INPUTS[@]}" -gt 0 ]]; then + ASSEMBLE_ARGS+=(--inputs "${INPUTS[@]}") +fi +if [[ "$ALLOW_MISSING" == "1" ]]; then + ASSEMBLE_ARGS+=(--allow-missing) +fi +if [[ "$OVERWRITE" == "1" ]]; then + ASSEMBLE_ARGS+=(--overwrite) +fi +if [[ "$DRY_RUN" == "1" ]]; then + ASSEMBLE_ARGS+=(--dry-run) +fi + +echo "[$(date)] Assembling yearly ERA5 NetCDF." +echo "Year: $YEAR" +echo "Output path: $OUTPUT_PATH" +"$SCRIPT_DIR/build_year_nc.sh" "${ASSEMBLE_ARGS[@]}" + +if [[ "$DRY_RUN" == "1" ]]; then + echo "[$(date)] Dry-run upload target: dataset/$HF_REPO_ID@$REVISION:$PATH_IN_REPO" + exit 0 +fi + +if [[ ! -f "$OUTPUT_PATH" ]]; then + echo "Assembled output not found: $OUTPUT_PATH" >&2 + exit 1 +fi + +if [[ -z "${HF_WRITE:-}" ]]; then + echo "HF_WRITE is required in the environment or .env." >&2 + exit 1 +fi + +echo "[$(date)] Uploading yearly ERA5 NetCDF to Hugging Face dataset." +echo "Destination: dataset/$HF_REPO_ID@$REVISION:$PATH_IN_REPO" + +"$PYTHON_BIN" - "$HF_REPO_ID" "$OUTPUT_PATH" "$PATH_IN_REPO" "$REVISION" "$COMMIT_MESSAGE" "$CREATE_REPO" "$PRIVATE" <<'PY' +import os +from pathlib import Path +import sys + +repo_id, output_path, path_in_repo, revision, commit_message, create_repo, private = sys.argv[1:8] +token = os.environ.get("HF_WRITE") + +try: + from huggingface_hub import HfApi +except ModuleNotFoundError as exc: + missing = exc.name or "a dependency" + raise SystemExit( + "Missing Python dependency while importing huggingface_hub: " + f"{missing}. Install with: python3 -m pip install --user huggingface_hub filelock" + ) from exc + +api = HfApi() +if create_repo == "1": + api.create_repo( + repo_id=repo_id, + repo_type="dataset", + token=token, + private=(private == "1"), + exist_ok=True, + ) + +result = api.upload_file( + path_or_fileobj=str(Path(output_path)), + path_in_repo=path_in_repo, + repo_id=repo_id, + repo_type="dataset", + revision=revision, + token=token, + commit_message=commit_message, +) +print(f"Upload complete: {result}") +PY diff --git a/scripts/era5/run.sh b/scripts/era5/run.sh index a708bdc..cca29c4 100755 --- a/scripts/era5/run.sh +++ b/scripts/era5/run.sh @@ -31,7 +31,7 @@ PREDICTION_TARGETS=(pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE) echo "Output CSV: $OUTPUT_CSV" -python3 -u eval/test_era5.py \ +python3 -u eval/era5/test_era5.py \ --run-path "$RUN_PATH" \ --checkpoint-path checkpoint-11.pth \ --db-path "$DB_PATH" \ diff --git a/scripts/era5/run_multi_gpu.sh b/scripts/era5/run_multi_gpu.sh index f77ddaa..060bb5e 100755 --- a/scripts/era5/run_multi_gpu.sh +++ b/scripts/era5/run_multi_gpu.sh @@ -28,6 +28,16 @@ append_prediction_targets() { done } +append_merge_prediction_targets() { + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + MERGE_PREDICTION_TARGET_LIST+=("$value") + fi + done +} + GPU_LOG_OUT="${GPU_LOG_OUT:-/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.out}" GPU_LOG_ERR="${GPU_LOG_ERR:-/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.error}" @@ -81,6 +91,11 @@ if [[ -n "$PREDICTION_TARGETS_ENV" ]]; then fi PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" +MERGE_PREDICTION_TARGETS_ENV="${MERGE_PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" +MERGE_PREDICTION_TARGET_LIST=() +append_merge_prediction_targets "$MERGE_PREDICTION_TARGETS_ENV" +MERGE_PREDICTION_TARGETS_VALUE="${MERGE_PREDICTION_TARGET_LIST[*]}" +export MERGE_PREDICTION_TARGETS="$MERGE_PREDICTION_TARGETS_VALUE" BATCH_SIZE_PER_GPU="${BATCH_SIZE_PER_GPU:-32768}" NUM_WORKERS_PER_GPU="${NUM_WORKERS_PER_GPU:-12}" @@ -119,11 +134,12 @@ echo "Dataloader workers per GPU: $NUM_WORKERS_PER_GPU" echo "Prefetch factor: $PREFETCH_FACTOR" echo "Dataloader in-order delivery: $DATALOADER_IN_ORDER_LABEL" echo "Prediction targets: $PREDICTION_TARGETS_VALUE" +echo "Merge prediction targets: $MERGE_PREDICTION_TARGETS_VALUE" echo "Temporary shard order key: __sample_order (dropped during post-processing)" echo "Distributed timeout minutes: $DIST_TIMEOUT_MINUTES" torchrun --standalone --nnodes=1 --nproc-per-node=4 \ - eval/test_era5_multi_gpu.py \ + eval/era5/test_era5_multi_gpu.py \ --run-path "$RUN_PATH" \ --checkpoint-path "$CHECKPOINT_PATH" \ --db-path "$DB_PATH" \ @@ -159,7 +175,7 @@ esac mkdir -p "$LOG_DIR" POST_JOB_ID="$( - sbatch --parsable \ + PREDICTION_TARGETS="$MERGE_PREDICTION_TARGETS_VALUE" sbatch --parsable \ --job-name "merge-shards-${DATE_TAG}" \ --output "$LOG_DIR/merge_prediction_shards_${DATE_TAG}.out" \ --error "$LOG_DIR/merge_prediction_shards_${DATE_TAG}.error" \ diff --git a/scripts/era5/submit_year.sh b/scripts/era5/submit_year.sh index e86a7c4..62630bf 100755 --- a/scripts/era5/submit_year.sh +++ b/scripts/era5/submit_year.sh @@ -2,6 +2,10 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + usage() { cat >&2 </dev/null || { + echo "sbatch not found in PATH; cannot submit ERA5 jobs." >&2 + exit 2 + } + [[ -f scripts/era5/run_multi_gpu.sh ]] || { + echo "Child job script not found: scripts/era5/run_multi_gpu.sh" >&2 + exit 2 + } + [[ -n "${SCRATCH:-}" ]] || { + echo "SCRATCH is not set; scripts/era5/run_multi_gpu.sh needs it to activate the environment." >&2 + exit 2 + } + [[ -f "$SCRATCH/env/ecoperceiver/bin/activate" ]] || { + echo "EcoPerceiver environment activation script not found: $SCRATCH/env/ecoperceiver/bin/activate" >&2 + exit 2 + } + [[ -d "$RUN_PATH" ]] || { + echo "Run path not found: $RUN_PATH" >&2 + exit 2 + } + [[ -n "$CHECKPOINT_PATH" ]] || { + echo "CHECKPOINT_PATH must not be empty." >&2 + exit 2 + } + checkpoint_path_for_check="$(resolve_checkpoint_path_for_check "$CHECKPOINT_PATH")" + [[ -f "$checkpoint_path_for_check" ]] || { + echo "Checkpoint not found: $checkpoint_path_for_check" >&2 + exit 2 + } +fi + mkdir -p "$LOG_DIR" starts=("${YEAR}-01-01" "${YEAR}-04-01" "${YEAR}-07-01" "${YEAR}-10-01") ends=("${YEAR}-03-31" "${YEAR}-06-30" "${YEAR}-09-30" "${YEAR}-12-31") PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" +MERGE_PREDICTION_TARGETS_VALUE="${MERGE_PREDICTION_TARGET_LIST[*]}" export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" +export MERGE_PREDICTION_TARGETS="$MERGE_PREDICTION_TARGETS_VALUE" export CHECKPOINT_PATH previous_dependency_id="" @@ -246,7 +312,8 @@ echo "DB path: $DB_PATH" echo "Run path: $RUN_PATH" echo "Checkpoint path: $CHECKPOINT_PATH" echo "Post format: $POST_FORMAT" -echo "Prediction targets: $PREDICTION_TARGETS_VALUE" +echo "Inference prediction targets: $PREDICTION_TARGETS_VALUE" +echo "Merge prediction targets: $MERGE_PREDICTION_TARGETS_VALUE" for chunk_index in "${!starts[@]}"; do initial_date="${starts[$chunk_index]}" @@ -272,7 +339,7 @@ for chunk_index in "${!starts[@]}"; do fi if [[ "$DRY_RUN" == "1" ]]; then - printf 'DRY RUN: PREDICTION_TARGETS=%q CHECKPOINT_PATH=%q sbatch' "$PREDICTION_TARGETS" "$CHECKPOINT_PATH" + printf 'DRY RUN: PREDICTION_TARGETS=%q MERGE_PREDICTION_TARGETS=%q CHECKPOINT_PATH=%q sbatch' "$PREDICTION_TARGETS" "$MERGE_PREDICTION_TARGETS" "$CHECKPOINT_PATH" printf ' %q' "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh printf '\n' job_id="dryrun-$((chunk_index + 1))" From 2a3afcd8ef75b0f98928926c2dc85cbfae8aed08 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Tue, 30 Jun 2026 12:28:34 -0400 Subject: [PATCH 12/14] refactor era5 yearly shard post-processing --- era5_pipeline/plot/viz_netcdf_structure.py | 4 +- eval/era5/build_year_nc_from_shards.py | 950 +++++++++++++++++++++ eval/era5/merge_era5_shards.py | 839 ------------------ eval/era5/test_era5_multi_gpu.py | 75 +- scripts/era5/build_year_nc.sh | 288 +------ scripts/era5/merge_prediction_shards.sh | 193 ----- scripts/era5/post_processing.sh | 623 ++++++++++++++ scripts/era5/push_year.sh | 417 +++++++++ scripts/era5/push_year_to_drive.sh | 266 ------ scripts/era5/push_year_to_hf.sh | 317 ------- scripts/era5/run.sh | 47 - scripts/era5/run_multi_gpu.sh | 68 +- scripts/era5/run_year.sh | 744 ++++++++++++++++ scripts/era5/submit_year.sh | 353 -------- 14 files changed, 2780 insertions(+), 2404 deletions(-) create mode 100644 eval/era5/build_year_nc_from_shards.py delete mode 100644 eval/era5/merge_era5_shards.py delete mode 100755 scripts/era5/merge_prediction_shards.sh create mode 100755 scripts/era5/post_processing.sh create mode 100755 scripts/era5/push_year.sh delete mode 100755 scripts/era5/push_year_to_drive.sh delete mode 100755 scripts/era5/push_year_to_hf.sh delete mode 100755 scripts/era5/run.sh create mode 100755 scripts/era5/run_year.sh delete mode 100755 scripts/era5/submit_year.sh diff --git a/era5_pipeline/plot/viz_netcdf_structure.py b/era5_pipeline/plot/viz_netcdf_structure.py index 6350612..185666a 100755 --- a/era5_pipeline/plot/viz_netcdf_structure.py +++ b/era5_pipeline/plot/viz_netcdf_structure.py @@ -9,14 +9,14 @@ from typing import Any -REPO_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = Path(__file__).resolve().parents[2] ERA5_CUBE_DIMS = ("valid_time", "latitude", "longitude") PREFERRED_DIM_ORDER = ("valid_time", "latitude", "longitude", "sample") DEFAULT_NETCDF_PATH = ( REPO_ROOT / "experiments/runs" / "final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC" - / "seed_0/eval/era5_predictions_20170601_to_20170630.nc" + / "seed_0/eval/era5_predictions_2017.nc" ) diff --git a/eval/era5/build_year_nc_from_shards.py b/eval/era5/build_year_nc_from_shards.py new file mode 100644 index 0000000..1c3f2b9 --- /dev/null +++ b/eval/era5/build_year_nc_from_shards.py @@ -0,0 +1,950 @@ +import argparse +import csv +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +DEFAULT_RUN_PATH = ( + "experiments/runs/" + "final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" +) +DEFAULT_PREDICTION_TARGETS = "pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE" +DEFAULT_CHUNK_ROWS = 2_000_000 +DEFAULT_WRITE_TIME_CHUNK = 186 +DEFAULT_MAX_MEMORY_GB = 470.0 +INTERNAL_ORDER_COLUMN = "__sample_order" +BASE_OUTPUT_COLUMNS = ("lat", "lon", "igbp", "timestamp") +REQUIRED_COORDINATE_COLUMNS = ("timestamp", "lat", "lon", "igbp") +DIRECT_DUPLICATE_POLICIES = ("error", "first", "last") +ERA5_TIME_DIM = "valid_time" +ERA5_SPATIAL_DIMS = ("latitude", "longitude") +ERA5_CUBE_DIMS = (ERA5_TIME_DIM, *ERA5_SPATIAL_DIMS) +ERA5_COORDINATES_ATTR = "number valid_time latitude longitude expver" +COORDINATE_DECIMALS = 6 +REGULAR_GRID_MIN_COVERAGE = 0.9 +ERA5_CHUNK_TARGETS = (186, 31, 360) +ERA5_GLOBAL_ATTRS = { + "GRIB_centre": "ecmf", + "GRIB_centreDescription": "European Centre for Medium-Range Weather Forecasts", + "GRIB_subCentre": 0, + "Conventions": "CF-1.7", + "institution": "European Centre for Medium-Range Weather Forecasts", +} +PREDICTION_TARGET_METADATA = { + "NEE": ("Predicted net ecosystem exchange", "umol CO2 m-2 s-1"), + "GPP_DT": ("Predicted daytime gross primary productivity", "umol CO2 m-2 s-1"), + "GPP_NT": ("Predicted nighttime gross primary productivity", "umol CO2 m-2 s-1"), + "RECO_DT": ("Predicted daytime ecosystem respiration", "umol CO2 m-2 s-1"), + "RECO_NT": ("Predicted nighttime ecosystem respiration", "umol CO2 m-2 s-1"), + "FCH4": ("Predicted methane flux", "nmol CH4 m-2 s-1"), + "LE": ("Predicted latent heat flux", "W m-2"), +} + + +def parse_prediction_targets(values: list[str] | None) -> tuple[str, ...] | None: + if values is None: + return None + + prediction_targets = [] + for value in values: + for target in value.replace(",", " ").split(): + if not target.startswith("pred_"): + target = f"pred_{target}" + prediction_targets.append(target) + + prediction_targets = list(dict.fromkeys(prediction_targets)) + if not prediction_targets: + raise ValueError("--prediction-targets requires at least one target when provided.") + return tuple(prediction_targets) + + +def resolve_output_columns( + input_columns: list[str], + prediction_targets: tuple[str, ...] | None, +) -> list[str]: + if prediction_targets is None: + return [column for column in input_columns if column != INTERNAL_ORDER_COLUMN] + + output_columns = list(BASE_OUTPUT_COLUMNS) + list(prediction_targets) + missing_columns = [column for column in output_columns if column not in input_columns] + if missing_columns: + available_predictions = ", ".join( + column for column in input_columns if column.startswith("pred_") + ) + raise ValueError( + "Requested output column(s) missing from shard header: " + f"{', '.join(missing_columns)}. " + f"Available prediction columns: {available_predictions or ''}" + ) + return output_columns + + +def coordinate_key(value) -> float: + return round(float(value), COORDINATE_DECIMALS) + + +def regularize_coordinate_axis(values, descending: bool, np): + values = np.asarray(values, dtype=np.float64) + if values.size < 3: + return values + + ascending_values = values[::-1] if descending else values + diffs = np.diff(ascending_values) + positive_diffs = diffs[diffs > 0] + if positive_diffs.size == 0: + return values + + rounded_diffs = np.round(positive_diffs, COORDINATE_DECIMALS) + unique_diffs, counts = np.unique(rounded_diffs, return_counts=True) + step = float(unique_diffs[np.argmax(counts)]) + if step <= 0 or not np.isfinite(step): + return values + + span = float(ascending_values[-1] - ascending_values[0]) + expected_count = int(round(span / step)) + 1 + if expected_count <= values.size: + return values + + coverage = values.size / expected_count + if coverage < REGULAR_GRID_MIN_COVERAGE: + return values + + regular_axis = np.round( + ascending_values[0] + np.arange(expected_count, dtype=np.float64) * step, + COORDINATE_DECIMALS, + ) + if descending: + regular_axis = regular_axis[::-1] + return regular_axis + + +def coordinate_increment(values, np) -> float: + if len(values) < 2: + return float("nan") + diffs = np.diff(np.asarray(values, dtype=np.float64)) + return float(abs(np.nanmedian(diffs))) + + +def era5_cube_chunks(shape: tuple[int, int, int]) -> tuple[int, int, int]: + return tuple( + max(1, min(size, target)) + for size, target in zip(shape, ERA5_CHUNK_TARGETS) + ) + + +def prediction_variable_attrs(column: str, latitudes, longitudes, np) -> dict[str, object]: + target = column.removeprefix("pred_").removeprefix("gt_") + long_name, units = PREDICTION_TARGET_METADATA.get( + target, + (column.replace("_", " "), "unknown"), + ) + if column.startswith("gt_"): + long_name = f"Ground truth {long_name.removeprefix('Predicted ').lower()}" + + return { + "long_name": long_name, + "units": units, + "standard_name": "unknown", + "coordinates": ERA5_COORDINATES_ATTR, + "GRIB_dataType": "fc", + "GRIB_numberOfPoints": int(len(latitudes) * len(longitudes)), + "GRIB_stepType": "instant", + "GRIB_stepUnits": 1, + "GRIB_gridType": "regular_ll", + "GRIB_typeOfLevel": "surface", + "GRIB_uvRelativeToGrid": 0, + "GRIB_NV": 0, + "GRIB_cfName": "unknown", + "GRIB_cfVarName": column, + "GRIB_shortName": column, + "GRIB_gridDefinitionDescription": "Latitude/Longitude Grid", + "GRIB_iDirectionIncrementInDegrees": coordinate_increment(longitudes, np), + "GRIB_iScansNegatively": 0, + "GRIB_jDirectionIncrementInDegrees": coordinate_increment(latitudes, np), + "GRIB_jPointsAreConsecutive": 0, + "GRIB_jScansPositively": 0, + "GRIB_latitudeOfFirstGridPointInDegrees": float(latitudes[0]) if len(latitudes) else np.nan, + "GRIB_latitudeOfLastGridPointInDegrees": float(latitudes[-1]) if len(latitudes) else np.nan, + "GRIB_longitudeOfFirstGridPointInDegrees": float(longitudes[0]) if len(longitudes) else np.nan, + "GRIB_longitudeOfLastGridPointInDegrees": float(longitudes[-1]) if len(longitudes) else np.nan, + "GRIB_Nx": int(len(longitudes)), + "GRIB_Ny": int(len(latitudes)), + "GRIB_missingValue": float(np.finfo(np.float32).max), + "GRIB_name": long_name, + "GRIB_totalNumber": 0, + "GRIB_units": units, + "GRIB_surface": 0.0, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="build_year_nc.sh", + description=( + "Build one calendar-year EcoPerceiver ERA5 NetCDF directly from " + "quarterly multi-GPU CSV shard directories." + ), + ) + parser.add_argument("year", nargs="?", help="Four-digit year to assemble.") + parser.add_argument("--year", dest="year_option", help="Four-digit year to assemble.") + parser.add_argument( + "--run-path", + default=os.environ.get("RUN_PATH", DEFAULT_RUN_PATH), + help="EcoPerceiver run directory. Default: RUN_PATH env or the repo default run.", + ) + parser.add_argument( + "--input-dir", + default=os.environ.get("INPUT_DIR"), + help="Directory containing quarter shard directories. Default: /eval.", + ) + parser.add_argument( + "--output-path", + default=os.environ.get("OUTPUT_PATH"), + help="Yearly NetCDF output path. Default: /era5_predictions_.nc.", + ) + parser.add_argument( + "--shard-dirs", + nargs=4, + metavar="PATH", + help=( + "Explicit Q1 Q2 Q3 Q4 shard directories. If omitted, directories are " + "inferred from .era5_predictions__to__multi_gpu_shards." + ), + ) + parser.add_argument( + "--num-shards", + type=int, + default=int(os.environ.get("NUM_SHARDS", "4")), + help="Expected rank_*.csv files per shard directory. Default: NUM_SHARDS env or 4.", + ) + parser.add_argument( + "--prediction-targets", + nargs="+", + default=os.environ.get("PREDICTION_TARGETS", DEFAULT_PREDICTION_TARGETS).split(), + help=( + "Prediction columns to include, with or without pred_ prefix. " + f"Default: {DEFAULT_PREDICTION_TARGETS}." + ), + ) + parser.add_argument( + "--netcdf-duplicate-policy", + choices=DIRECT_DUPLICATE_POLICIES, + default=os.environ.get("NETCDF_DUPLICATE_POLICY", "last"), + help=( + "How duplicate (timestamp, lat, lon) rows are handled while filling " + "the yearly cube. Default: last." + ), + ) + parser.add_argument( + "--chunk-rows", + type=int, + default=int(os.environ.get("BUILD_YEAR_CHUNK_ROWS", str(DEFAULT_CHUNK_ROWS))), + help=f"Rows per pandas CSV chunk. Default: {DEFAULT_CHUNK_ROWS}.", + ) + parser.add_argument( + "--write-time-chunk", + type=int, + default=int(os.environ.get("BUILD_YEAR_WRITE_TIME_CHUNK", str(DEFAULT_WRITE_TIME_CHUNK))), + help=f"Time steps per NetCDF write slice. Default: {DEFAULT_WRITE_TIME_CHUNK}.", + ) + parser.add_argument( + "--max-memory-gb", + type=float, + default=float(os.environ.get("BUILD_YEAR_MAX_MEMORY_GB", str(DEFAULT_MAX_MEMORY_GB))), + help=f"Refuse estimated array allocations above this many GiB. Default: {DEFAULT_MAX_MEMORY_GB}.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Replace an existing output file.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print inferred paths, selected columns, and memory estimate without writing.", + ) + parser.add_argument( + "--validate-shard-order", + action="store_true", + help=( + "Validate that __sample_order is nondecreasing within each quarter's " + "rank files. This is optional because direct NetCDF filling tracks " + "__sample_order per output cell." + ), + ) + parser.add_argument( + "--no-validate-shard-order", + action="store_false", + dest="validate_shard_order", + help=argparse.SUPPRESS, + ) + + args = parser.parse_args() + year = args.year_option or args.year + if year is None: + parser.error("YEAR is required as a positional argument or --year.") + if not year.isdigit() or len(year) != 4: + parser.error(f"YEAR must be a four-digit year, got: {year}") + if args.num_shards <= 0: + parser.error("--num-shards must be positive.") + if args.chunk_rows <= 0: + parser.error("--chunk-rows must be positive.") + if args.write_time_chunk <= 0: + parser.error("--write-time-chunk must be positive.") + if args.max_memory_gb <= 0: + parser.error("--max-memory-gb must be positive.") + + args.year = year + args.prediction_targets = parse_prediction_targets(args.prediction_targets) + return args + + +def quarter_date_tags(year: str) -> list[str]: + ranges = ( + (f"{year}0101", f"{year}0331"), + (f"{year}0401", f"{year}0630"), + (f"{year}0701", f"{year}0930"), + (f"{year}1001", f"{year}1231"), + ) + return [f"{start}_to_{end}" for start, end in ranges] + + +def resolve_shard_dirs(args: argparse.Namespace) -> list[Path]: + run_path = Path(args.run_path).expanduser() + input_dir = Path(args.input_dir).expanduser() if args.input_dir else run_path / "eval" + if args.shard_dirs: + shard_dirs = [Path(path).expanduser() for path in args.shard_dirs] + else: + shard_dirs = [ + input_dir / f".era5_predictions_{date_tag}_multi_gpu_shards" + for date_tag in quarter_date_tags(args.year) + ] + + missing = [path for path in shard_dirs if not path.is_dir()] + if missing: + missing_list = "\n ".join(str(path) for path in missing) + raise FileNotFoundError(f"Missing shard directory input(s):\n {missing_list}") + return shard_dirs + + +def resolve_output_path(args: argparse.Namespace) -> Path: + run_path = Path(args.run_path).expanduser() + input_dir = Path(args.input_dir).expanduser() if args.input_dir else run_path / "eval" + return ( + Path(args.output_path).expanduser() + if args.output_path + else input_dir / f"era5_predictions_{args.year}.nc" + ) + + +def resolve_rank_paths(shard_dir: Path, num_shards: int) -> list[Path]: + shard_paths = [shard_dir / f"rank_{rank:05d}.csv" for rank in range(num_shards)] + missing = [path for path in shard_paths if not path.is_file()] + if missing: + missing_list = "\n ".join(str(path) for path in missing) + raise FileNotFoundError(f"Missing shard file(s) in {shard_dir}:\n {missing_list}") + return shard_paths + + +def read_csv_header(path: Path) -> list[str]: + with path.open("r", newline="", encoding="utf-8", buffering=1024 * 1024) as handle: + header = next(csv.reader(handle), None) + if header is None: + raise RuntimeError(f"Shard file is empty: {path}") + return header + + +def resolve_headers( + shard_groups: list[tuple[Path, list[Path]]], + prediction_targets: tuple[str, ...] | None, +) -> tuple[list[str], list[str], list[str]]: + expected_header: list[str] | None = None + for _, shard_paths in shard_groups: + for path in shard_paths: + header = read_csv_header(path) + if expected_header is None: + expected_header = header + elif header != expected_header: + raise RuntimeError(f"CSV header mismatch in shard {path}") + + if expected_header is None: + raise RuntimeError("No shard headers were available.") + + output_columns = resolve_output_columns(expected_header, prediction_targets) + missing_required = [column for column in REQUIRED_COORDINATE_COLUMNS if column not in output_columns] + if missing_required: + raise RuntimeError( + "Direct yearly NetCDF output requires coordinate column(s): " + f"{', '.join(missing_required)}" + ) + + prediction_columns = [ + column for column in output_columns if column not in BASE_OUTPUT_COLUMNS + ] + if not prediction_columns: + raise RuntimeError("No prediction columns selected for NetCDF output.") + return expected_header, output_columns, prediction_columns + + +def iter_csv_chunks(path: Path, use_columns: list[str], chunk_rows: int, pandas, dtypes=None): + yield from pandas.read_csv( + path, + usecols=use_columns, + chunksize=chunk_rows, + dtype=dtypes, + ) + + +def scan_coordinate_axes( + shard_groups: list[tuple[Path, list[Path]]], + chunk_rows: int, +) -> tuple[object, object]: + import numpy as np + import pandas as pd + + lat_values: set[float] = set() + lon_values: set[float] = set() + dtypes = {"lat": "float64", "lon": "float64"} + for shard_dir, shard_paths in shard_groups: + print(f"Scanning coordinate axes in {shard_dir}") + for path in shard_paths: + for chunk in iter_csv_chunks(path, ["lat", "lon"], chunk_rows, pd, dtypes): + lat_values.update(float(value) for value in chunk["lat"].dropna().unique()) + lon_values.update(float(value) for value in chunk["lon"].dropna().unique()) + + if not lat_values or not lon_values: + raise RuntimeError("No latitude/longitude coordinates found in shard inputs.") + + latitudes = regularize_coordinate_axis( + np.sort(np.asarray(list(lat_values), dtype=np.float64))[::-1], + descending=True, + np=np, + ) + longitudes = regularize_coordinate_axis( + np.sort(np.asarray(list(lon_values), dtype=np.float64)), + descending=False, + np=np, + ) + return latitudes, longitudes + + +def yearly_timestamp_axis(year: str): + import pandas as pd + + start = f"{year}-01-01 00:00:00" + end = f"{year}-12-31 23:00:00" + valid_times = pd.date_range(start, end, freq="h") + timestamp_keys = valid_times.strftime("%Y%m%d%H%M%S").astype("int64").to_numpy() + return valid_times.to_numpy(dtype="datetime64[ns]"), timestamp_keys + + +def timestamp_seconds(valid_times, numpy) -> object: + seconds = valid_times.astype("datetime64[s]").astype("int64") + return numpy.asarray(seconds, dtype=numpy.int64) + + +def gibibytes(num_bytes: int | float) -> float: + return float(num_bytes) / (1024.0**3) + + +def estimate_array_bytes( + num_times: int, + num_latitudes: int, + num_longitudes: int, + num_predictions: int, + duplicate_policy: str, + tracks_sample_order: bool, +) -> int: + cube_cells = num_times * num_latitudes * num_longitudes + total = cube_cells * num_predictions * 4 + if duplicate_policy == "error": + total += cube_cells + if tracks_sample_order: + total += cube_cells * 8 + return total + + +def lat_lon_lookup(values) -> dict[float, int]: + return {coordinate_key(value): index for index, value in enumerate(values)} + + +def map_series(series, lookup: dict, name: str): + mapped = series.map(lookup) + if mapped.isna().any(): + examples = series[mapped.isna()].head(5).tolist() + raise RuntimeError(f"Cannot map {name} value(s) onto the NetCDF grid. Examples: {examples}") + return mapped.to_numpy(dtype="int64") + + +def update_igbp_array(igbp_array, lat_indices, lon_indices, values, numpy, pandas) -> int: + frame = pandas.DataFrame( + { + "lat_idx": lat_indices, + "lon_idx": lon_indices, + "igbp": values.fillna("").astype(str).str.slice(0, 3), + } + ).drop_duplicates(["lat_idx", "lon_idx"], keep="last") + if frame.empty: + return 0 + + row_lat = frame["lat_idx"].to_numpy(dtype=numpy.int64) + row_lon = frame["lon_idx"].to_numpy(dtype=numpy.int64) + new_values = frame["igbp"].to_numpy(dtype=" int | None: + if INTERNAL_ORDER_COLUMN not in chunk.columns: + return previous_order + orders = chunk[INTERNAL_ORDER_COLUMN].to_numpy(dtype="int64") + if orders.size == 0: + return previous_order + if previous_order is not None and int(orders[0]) < previous_order: + raise RuntimeError( + f"{INTERNAL_ORDER_COLUMN} decreases between shard chunks in {shard_dir}: {path}" + ) + if orders.size > 1 and (orders[1:] < orders[:-1]).any(): + raise RuntimeError(f"{INTERNAL_ORDER_COLUMN} decreases within shard file: {path}") + return int(orders[-1]) + + +def fill_prediction_arrays( + shard_groups: list[tuple[Path, list[Path]]], + header: list[str], + prediction_columns: list[str], + latitudes, + longitudes, + timestamp_keys, + duplicate_policy: str, + chunk_rows: int, + validate_shard_order: bool, +): + import numpy as np + import pandas as pd + + cube_shape = (len(timestamp_keys), len(latitudes), len(longitudes)) + arrays = { + column: np.full(cube_shape, np.nan, dtype=np.float32) + for column in prediction_columns + } + igbp_array = np.full(cube_shape[1:], "", dtype=" previous_orders + else: + better = sample_orders < previous_orders + duplicate_rows_skipped += int(len(better) - better.sum()) + time_indices = time_indices[better] + lat_indices = lat_indices[better] + lon_indices = lon_indices[better] + sample_orders = sample_orders[better] + sample_order_array[(time_indices, lat_indices, lon_indices)] = sample_orders + keep_for_values = keep + better_for_values = better + elif duplicate_policy == "error": + keep = drop_duplicate_indices_for_policy( + time_indices, + lat_indices, + lon_indices, + duplicate_policy, + pd, + ) + if not isinstance(keep, slice): + duplicate_rows_skipped += int(len(keep) - keep.sum()) + time_indices = time_indices[keep] + lat_indices = lat_indices[keep] + lon_indices = lon_indices[keep] + indexer = (time_indices, lat_indices, lon_indices) + if filled_mask[indexer].any(): + raise RuntimeError( + "Duplicate row(s) found for (timestamp, lat, lon) across CSV chunks." + ) + filled_mask[(time_indices, lat_indices, lon_indices)] = True + keep_for_values = keep + better_for_values = slice(None) + elif duplicate_policy != "last": + raise ValueError(f"Unsupported duplicate policy: {duplicate_policy}") + else: + keep_for_values = slice(None) + better_for_values = slice(None) + + indexer = (time_indices, lat_indices, lon_indices) + for column in prediction_columns: + values = chunk[column] + if not isinstance(keep_for_values, slice): + values = values.iloc[keep_for_values] + if not isinstance(better_for_values, slice): + values = values.iloc[better_for_values] + arrays[column][indexer] = values.to_numpy(dtype=np.float32) + rows_written += int(len(time_indices)) + if rows_read and rows_read % (chunk_rows * 20) < chunk_rows: + print(f" rows read: {rows_read:,}; rows written: {rows_written:,}") + + if igbp_conflicts: + print(f"Warning: observed {igbp_conflicts:,} conflicting IGBP cell assignments; kept last value.") + if duplicate_rows_skipped: + print(f"Skipped {duplicate_rows_skipped:,} duplicate row(s) with policy {duplicate_policy!r}.") + return arrays, igbp_array, rows_read, rows_written + + +def write_attrs(attrs, values: dict[str, object]) -> None: + for key, value in values.items(): + attrs[key] = value + + +def create_numeric_variable(file_handle, name, dimensions, dtype, fillvalue=None, chunks=None): + kwargs = {} + if chunks is not None: + kwargs["chunks"] = chunks + if fillvalue is not None: + kwargs["fillvalue"] = fillvalue + if dimensions: + kwargs.update({"compression": "gzip", "compression_opts": 1, "shuffle": True}) + return file_handle.create_variable(name, dimensions, dtype=dtype, **kwargs) + + +def write_yearly_netcdf( + output_path: Path, + arrays: dict[str, object], + igbp_array, + valid_times, + timestamp_keys, + latitudes, + longitudes, + rows_read: int, + rows_written: int, + shard_dirs: list[Path], + duplicate_policy: str, + write_time_chunk: int, +) -> None: + import h5netcdf + import numpy as np + + output_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = output_path.with_suffix(output_path.suffix + ".tmp") + tmp_path.unlink(missing_ok=True) + + cube_shape = (len(valid_times), len(latitudes), len(longitudes)) + cube_chunks = era5_cube_chunks(cube_shape) + write_step = max(1, min(int(write_time_chunk), cube_shape[0])) + + try: + with h5netcdf.File(tmp_path, "w") as dataset: + dataset.dimensions = { + ERA5_TIME_DIM: cube_shape[0], + "latitude": cube_shape[1], + "longitude": cube_shape[2], + } + write_attrs(dataset.attrs, ERA5_GLOBAL_ATTRS) + dataset.attrs["title"] = "EcoPerceiver predictions on an ERA5 latitude-longitude grid" + dataset.attrs["source"] = "EcoPerceiver ERA5 torchrun inference shards" + dataset.attrs["history"] = ( + f"{datetime.now(timezone.utc).isoformat()} EcoPerceiver predictions " + "converted directly from quarterly shard CSVs to one calendar-year " + "ERA5-like NetCDF via eval/era5/build_year_nc_from_shards.py" + ) + dataset.attrs["num_shard_dirs"] = len(shard_dirs) + dataset.attrs["assembled_input_shard_dirs"] = " ".join(str(path) for path in shard_dirs) + dataset.attrs["num_input_rows"] = int(rows_read) + dataset.attrs["num_output_rows"] = int(rows_written) + dataset.attrs["netcdf_duplicate_policy"] = duplicate_policy + dataset.attrs["timestamp_source_column"] = "timestamp" + dataset.attrs["timestamp_source_format"] = "YYYYMMDDHHMMSS" + dataset.attrs["timestamp_coordinate"] = ERA5_TIME_DIM + dataset.attrs["timestamp_note"] = ( + "valid_time coordinate values are parsed from the shard timestamp " + "column. They may be local wall-clock times when the source ERA5 " + "database was built with local timestamp_policy." + ) + + number_var = dataset.create_variable("number", (), dtype="int64") + number_var[...] = np.asarray(0, dtype=np.int64) + number_var.attrs["long_name"] = "ensemble member numerical id" + number_var.attrs["units"] = "1" + number_var.attrs["standard_name"] = "realization" + + time_var = dataset.create_variable(ERA5_TIME_DIM, (ERA5_TIME_DIM,), dtype="int64") + time_var[:] = timestamp_seconds(valid_times, np) + time_var.attrs["units"] = "seconds since 1970-01-01" + time_var.attrs["calendar"] = "proleptic_gregorian" + time_var.attrs["long_name"] = "time" + time_var.attrs["standard_name"] = "time" + + lat_var = dataset.create_variable( + "latitude", + ("latitude",), + dtype="float64", + fillvalue=np.nan, + ) + lat_var[:] = latitudes + lat_var.attrs["long_name"] = "latitude" + lat_var.attrs["units"] = "degrees_north" + lat_var.attrs["standard_name"] = "latitude" + lat_var.attrs["stored_direction"] = "decreasing" + + lon_var = dataset.create_variable( + "longitude", + ("longitude",), + dtype="float64", + fillvalue=np.nan, + ) + lon_var[:] = longitudes + lon_var.attrs["long_name"] = "longitude" + lon_var.attrs["units"] = "degrees_east" + lon_var.attrs["standard_name"] = "longitude" + + expver_var = dataset.create_variable("expver", (ERA5_TIME_DIM,), dtype="S4") + expver_var[:] = np.full(cube_shape[0], b"0001", dtype="S4") + + igbp_var = dataset.create_variable( + "igbp", + ERA5_SPATIAL_DIMS, + dtype="S3", + chunks=tuple(max(1, min(size, target)) for size, target in zip(cube_shape[1:], cube_chunks[1:])), + compression="gzip", + compression_opts=1, + shuffle=True, + ) + igbp_var[:] = igbp_array.astype("S3") + igbp_var.attrs["long_name"] = "IGBP land cover class" + igbp_var.attrs["coordinates"] = "latitude longitude" + + for name in list(arrays): + array = arrays.pop(name) + variable = create_numeric_variable( + dataset, + name, + ERA5_CUBE_DIMS, + dtype="float32", + fillvalue=np.float32(np.nan), + chunks=cube_chunks, + ) + variable.attrs.update( + prediction_variable_attrs(name, latitudes, longitudes, np) + ) + variable.attrs["coordinates"] = ERA5_COORDINATES_ATTR + for start in range(0, cube_shape[0], write_step): + end = min(cube_shape[0], start + write_step) + variable[start:end, :, :] = array[start:end, :, :] + del array + print(f"Wrote variable {name}") + + tmp_path.replace(output_path) + finally: + tmp_path.unlink(missing_ok=True) + + +def main() -> int: + args = parse_args() + shard_dirs = resolve_shard_dirs(args) + output_path = resolve_output_path(args) + if output_path.exists() and not args.overwrite: + raise FileExistsError(f"Output already exists. Use --overwrite to replace it: {output_path}") + + shard_groups = [(path, resolve_rank_paths(path, args.num_shards)) for path in shard_dirs] + header, output_columns, prediction_columns = resolve_headers( + shard_groups, + args.prediction_targets, + ) + valid_times, timestamp_keys = yearly_timestamp_axis(args.year) + + print("ERA5 yearly NetCDF direct shard build") + print(f"Year: {args.year}") + print("Shard directories:") + for path, shard_paths in shard_groups: + print(f" {path} ({len(shard_paths)} shard files)") + print(f"Output: {output_path}") + print("Writer: h5netcdf") + print(f"Prediction columns: {', '.join(prediction_columns)}") + print(f"Duplicate policy: {args.netcdf_duplicate_policy}") + print(f"CSV chunk rows: {args.chunk_rows:,}") + + latitudes, longitudes = scan_coordinate_axes(shard_groups, args.chunk_rows) + tracks_sample_order = ( + args.netcdf_duplicate_policy in {"first", "last"} + and INTERNAL_ORDER_COLUMN in header + ) + estimated_bytes = estimate_array_bytes( + len(valid_times), + len(latitudes), + len(longitudes), + len(prediction_columns), + args.netcdf_duplicate_policy, + tracks_sample_order, + ) + print( + "Output cube shape: " + f"time={len(valid_times):,}, latitude={len(latitudes):,}, longitude={len(longitudes):,}" + ) + print(f"Estimated in-memory arrays: {gibibytes(estimated_bytes):.1f} GiB") + if gibibytes(estimated_bytes) > args.max_memory_gb: + raise MemoryError( + f"Estimated array allocation is {gibibytes(estimated_bytes):.1f} GiB, " + f"above --max-memory-gb={args.max_memory_gb:.1f} GiB." + ) + + if args.dry_run: + print("Dry run only; no file written.") + return 0 + + arrays, igbp_array, rows_read, rows_written = fill_prediction_arrays( + shard_groups=shard_groups, + header=header, + prediction_columns=prediction_columns, + latitudes=latitudes, + longitudes=longitudes, + timestamp_keys=timestamp_keys, + duplicate_policy=args.netcdf_duplicate_policy, + chunk_rows=args.chunk_rows, + validate_shard_order=args.validate_shard_order, + ) + print(f"Finished filling arrays from {rows_read:,} input row(s).") + write_yearly_netcdf( + output_path=output_path, + arrays=arrays, + igbp_array=igbp_array, + valid_times=valid_times, + timestamp_keys=timestamp_keys, + latitudes=latitudes, + longitudes=longitudes, + rows_read=rows_read, + rows_written=rows_written, + shard_dirs=shard_dirs, + duplicate_policy=args.netcdf_duplicate_policy, + write_time_chunk=args.write_time_chunk, + ) + print(f"Saved yearly NetCDF to {output_path}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/eval/era5/merge_era5_shards.py b/eval/era5/merge_era5_shards.py deleted file mode 100644 index 5f0213c..0000000 --- a/eval/era5/merge_era5_shards.py +++ /dev/null @@ -1,839 +0,0 @@ -import argparse -import csv -import io -import shutil -import subprocess -import os -from datetime import datetime, timezone -from pathlib import Path - -INTERNAL_ORDER_COLUMN = "__sample_order" -BASE_OUTPUT_COLUMNS = ("lat", "lon", "igbp", "timestamp") -DEFAULT_SORT_COLUMNS = ("lat", "lon", "timestamp") -ERA5_TIME_DIM = "valid_time" -ERA5_SPATIAL_DIMS = ("latitude", "longitude") -ERA5_CUBE_DIMS = (ERA5_TIME_DIM, *ERA5_SPATIAL_DIMS) -ERA5_COORDINATES_ATTR = "number valid_time latitude longitude expver" -COORDINATE_DECIMALS = 6 -REGULAR_GRID_MIN_COVERAGE = 0.9 -ERA5_CHUNK_TARGETS = (186, 31, 360) -ERA5_GLOBAL_ATTRS = { - "GRIB_centre": "ecmf", - "GRIB_centreDescription": "European Centre for Medium-Range Weather Forecasts", - "GRIB_subCentre": 0, - "Conventions": "CF-1.7", - "institution": "European Centre for Medium-Range Weather Forecasts", -} -PREDICTION_TARGET_METADATA = { - "NEE": ("Predicted net ecosystem exchange", "umol CO2 m-2 s-1"), - "GPP_DT": ("Predicted daytime gross primary productivity", "umol CO2 m-2 s-1"), - "GPP_NT": ("Predicted nighttime gross primary productivity", "umol CO2 m-2 s-1"), - "RECO_DT": ("Predicted daytime ecosystem respiration", "umol CO2 m-2 s-1"), - "RECO_NT": ("Predicted nighttime ecosystem respiration", "umol CO2 m-2 s-1"), - "FCH4": ("Predicted methane flux", "nmol CH4 m-2 s-1"), - "LE": ("Predicted latent heat flux", "W m-2"), -} - - -def parse_args(): - parser = argparse.ArgumentParser(description="Merge ERA5 torchrun CSV shards.") - parser.add_argument( - "--shard-dir", - type=Path, - required=True, - help="Directory containing rank_*.csv shard files.", - ) - parser.add_argument( - "--format", - choices=("csv", "netcdf"), - default="csv", - help="Post-processing output format.", - ) - parser.add_argument( - "--output-path", - type=Path, - default=None, - help="Final output path.", - ) - parser.add_argument( - "--output-csv", - type=Path, - default=None, - help="Final merged CSV path. Kept as an alias for --output-path.", - ) - parser.add_argument( - "--num-shards", - type=int, - default=None, - help="Expected number of rank shards. If omitted, all rank_*.csv files are merged.", - ) - parser.add_argument( - "--cleanup", - action="store_true", - help="Remove the shard directory after a successful merge.", - ) - parser.add_argument( - "--prediction-targets", - nargs="+", - default=None, - metavar="TARGET", - help=( - "Prediction columns to keep in the final output. Accepts comma-separated " - "or space-separated values, with or without the pred_ prefix. " - "Default: keep all prediction columns present in the shards." - ), - ) - parser.add_argument( - "--sort-output", - action="store_true", - help=( - "Sort final rows during post-processing. Uses the temporary " - "__sample_order shard column when present, otherwise falls back to " - "lat, lon, and timestamp." - ), - ) - parser.add_argument( - "--sort-tmp-dir", - type=Path, - default=None, - help="Temporary directory for external sort files (default: sort chooses its temp location).", - ) - parser.add_argument( - "--netcdf-duplicate-policy", - choices=("error", "first", "last", "mean"), - default="error", - help=( - "How NetCDF cube output handles duplicate (timestamp, lat, lon) rows. " - "Default: error." - ), - ) - return parser.parse_args() - - -def resolve_shard_paths(shard_dir: Path, num_shards: int | None) -> list[Path]: - if num_shards is not None: - if num_shards <= 0: - raise ValueError("--num-shards must be positive when provided.") - shard_paths = [shard_dir / f"rank_{rank:05d}.csv" for rank in range(num_shards)] - else: - shard_paths = sorted(shard_dir.glob("rank_*.csv")) - - if not shard_paths: - raise FileNotFoundError(f"No rank_*.csv shards found in {shard_dir}") - - missing = [path for path in shard_paths if not path.exists()] - if missing: - missing_list = ", ".join(str(path) for path in missing) - raise FileNotFoundError(f"Missing expected shard file(s): {missing_list}") - - return shard_paths - - -def parse_prediction_targets(values: list[str] | None) -> tuple[str, ...] | None: - if values is None: - return None - - prediction_targets = [] - for value in values: - for target in value.replace(",", " ").split(): - if not target.startswith("pred_"): - target = f"pred_{target}" - prediction_targets.append(target) - - prediction_targets = list(dict.fromkeys(prediction_targets)) - if not prediction_targets: - raise ValueError("--prediction-targets requires at least one target when provided.") - return tuple(prediction_targets) - - -def resolve_output_columns( - input_columns: list[str], - prediction_targets: tuple[str, ...] | None, -) -> list[str]: - if prediction_targets is None: - return [column for column in input_columns if column != INTERNAL_ORDER_COLUMN] - - output_columns = list(BASE_OUTPUT_COLUMNS) + list(prediction_targets) - missing_columns = [column for column in output_columns if column not in input_columns] - if missing_columns: - available_predictions = ", ".join( - column for column in input_columns if column.startswith("pred_") - ) - raise ValueError( - "Requested output column(s) missing from shard header: " - f"{', '.join(missing_columns)}. " - f"Available prediction columns: {available_predictions or ''}" - ) - return output_columns - - -def merge_csv_shards( - shard_paths: list[Path], - output_csv_path: Path, - prediction_targets: tuple[str, ...] | None, - sort_output: bool, - sort_tmp_dir: Path | None, -): - output_csv_path.parent.mkdir(parents=True, exist_ok=True) - output_tmp = output_csv_path.with_suffix(output_csv_path.suffix + ".tmp") - sorted_rows_tmp = output_csv_path.with_suffix(output_csv_path.suffix + ".rows.sorted.tmp") - header_written = False - expected_header = None - output_columns = None - output_indices = None - - for shard_path in shard_paths: - with shard_path.open("r", newline="", encoding="utf-8", buffering=1024 * 1024) as shard_file: - reader = csv.reader(shard_file) - header = next(reader, None) - if header is None: - continue - if expected_header is None: - expected_header = header - output_columns = resolve_output_columns(header, prediction_targets) - output_indices = [header.index(column) for column in output_columns] - elif header != expected_header: - raise RuntimeError(f"CSV header mismatch in shard {shard_path}") - if output_columns is None or output_indices is None or expected_header is None: - raise RuntimeError("No shard headers were available to merge.") - - if sort_output: - sort_key_args = [] - if INTERNAL_ORDER_COLUMN in expected_header: - sort_input_columns = [INTERNAL_ORDER_COLUMN] + output_columns - sort_input_indices = [expected_header.index(column) for column in sort_input_columns] - sort_key_args.append("-k1,1n") - drop_sort_key_column = True - else: - sort_input_columns = output_columns - sort_input_indices = output_indices - for column in DEFAULT_SORT_COLUMNS: - try: - column_index = sort_input_columns.index(column) + 1 - except ValueError as exc: - raise RuntimeError(f"Cannot sort output; missing column: {column}") from exc - sort_key_args.append(f"-k{column_index},{column_index}n") - drop_sort_key_column = False - - sort_command = ["sort", "-t,", *sort_key_args] - if sort_tmp_dir is not None: - sort_tmp_dir.mkdir(parents=True, exist_ok=True) - sort_command.extend(["-T", str(sort_tmp_dir)]) - sort_env = os.environ.copy() - sort_env["LC_ALL"] = "C" - try: - with sorted_rows_tmp.open("wb") as sorted_rows_file: - sort_proc = subprocess.Popen( - sort_command, - stdin=subprocess.PIPE, - stdout=sorted_rows_file, - env=sort_env, - ) - assert sort_proc.stdin is not None - sort_stdin = io.TextIOWrapper( - sort_proc.stdin, - encoding="utf-8", - newline="", - write_through=True, - ) - writer = csv.writer(sort_stdin, lineterminator="\n") - for shard_path in shard_paths: - with shard_path.open("r", newline="", encoding="utf-8", buffering=1024 * 1024) as shard_file: - reader = csv.reader(shard_file) - next(reader, None) - for row in reader: - writer.writerow([row[index] for index in sort_input_indices]) - sort_stdin.close() - returncode = sort_proc.wait() - if returncode != 0: - raise subprocess.CalledProcessError(returncode, sort_command) - - with output_tmp.open("w", newline="", encoding="utf-8", buffering=1024 * 1024) as out_file: - writer = csv.writer(out_file) - writer.writerow(output_columns) - if drop_sort_key_column: - with sorted_rows_tmp.open("r", newline="", encoding="utf-8", buffering=1024 * 1024) as rows_file: - reader = csv.reader(rows_file) - for row in reader: - writer.writerow(row[1:]) - else: - with sorted_rows_tmp.open("r", encoding="utf-8", buffering=1024 * 1024) as rows_file: - shutil.copyfileobj(rows_file, out_file, length=1024 * 1024) - output_tmp.replace(output_csv_path) - finally: - sorted_rows_tmp.unlink(missing_ok=True) - output_tmp.unlink(missing_ok=True) - return - - if prediction_targets is None and INTERNAL_ORDER_COLUMN not in expected_header: - with output_tmp.open("wb") as out_file: - for shard_path in shard_paths: - with shard_path.open("rb") as shard_file: - header = shard_file.readline() - if not header: - continue - header_columns = next( - csv.reader([header.decode("utf-8").rstrip("\r\n")]) - ) - if header_columns != expected_header: - raise RuntimeError(f"CSV header mismatch in shard {shard_path}") - - if not header_written: - out_file.write(header) - header_written = True - shutil.copyfileobj(shard_file, out_file, length=1024 * 1024) - - output_tmp.replace(output_csv_path) - return - - with output_tmp.open("w", newline="", encoding="utf-8", buffering=1024 * 1024) as out_file: - writer = csv.writer(out_file) - writer.writerow(output_columns) - for shard_path in shard_paths: - with shard_path.open("r", newline="", encoding="utf-8", buffering=1024 * 1024) as shard_file: - reader = csv.reader(shard_file) - header = next(reader, None) - if header is None: - continue - if header != expected_header: - raise RuntimeError(f"CSV header mismatch in shard {shard_path}") - - for row in reader: - writer.writerow([row[index] for index in output_indices]) - - output_tmp.replace(output_csv_path) - - -def write_netcdf_from_csv_shards( - shard_paths: list[Path], - output_netcdf_path: Path, - prediction_targets: tuple[str, ...] | None, - sort_output: bool, - duplicate_policy: str, -): - try: - import numpy as np - import pandas as pd - import xarray as xr - except ImportError as exc: - raise RuntimeError( - "NetCDF output requires numpy, pandas, and xarray in the active environment." - ) from exc - - output_netcdf_path.parent.mkdir(parents=True, exist_ok=True) - input_columns = pd.read_csv(shard_paths[0], nrows=0).columns.tolist() - output_columns = resolve_output_columns(input_columns, prediction_targets) - use_columns = None - if prediction_targets is not None or INTERNAL_ORDER_COLUMN in input_columns: - use_columns = list(output_columns) - if sort_output and INTERNAL_ORDER_COLUMN in input_columns: - use_columns = [INTERNAL_ORDER_COLUMN] + use_columns - - frames = [pd.read_csv(shard_path, usecols=use_columns) for shard_path in shard_paths] - if not frames: - raise RuntimeError("No shard rows were available to write NetCDF output.") - - df = pd.concat(frames, ignore_index=True) - if sort_output: - if INTERNAL_ORDER_COLUMN in df.columns: - df[INTERNAL_ORDER_COLUMN] = pd.to_numeric(df[INTERNAL_ORDER_COLUMN], errors="coerce") - df = df.sort_values(INTERNAL_ORDER_COLUMN, kind="mergesort", ignore_index=True) - else: - missing_sort_columns = [column for column in DEFAULT_SORT_COLUMNS if column not in df.columns] - if missing_sort_columns: - raise RuntimeError( - "Cannot sort NetCDF output; missing column(s): " - f"{', '.join(missing_sort_columns)}" - ) - for column in DEFAULT_SORT_COLUMNS: - df[column] = pd.to_numeric(df[column], errors="coerce") - df = df.sort_values(list(DEFAULT_SORT_COLUMNS), kind="mergesort", ignore_index=True) - if INTERNAL_ORDER_COLUMN in df.columns: - df = df.drop(columns=[INTERNAL_ORDER_COLUMN]) - df = df.loc[:, output_columns] - for column in df.columns: - if column in {"lat", "lon", "timestamp"} or column.startswith(("pred_", "gt_")): - numeric = pd.to_numeric(df[column], errors="coerce") - if column == "timestamp" and not numeric.isna().any(): - df[column] = numeric.astype("int64") - else: - df[column] = numeric - - dataset = build_era5_cube_dataset( - df, - output_columns, - len(shard_paths), - duplicate_policy, - np, - pd, - xr, - ) - - output_tmp = output_netcdf_path.with_suffix(output_netcdf_path.suffix + ".tmp") - dataset.to_netcdf( - output_tmp, - engine="h5netcdf", - encoding=era5_netcdf_encoding(dataset, np), - ) - output_tmp.replace(output_netcdf_path) - - -def build_era5_cube_dataset(df, output_columns, num_shards, duplicate_policy, np, pd, xr): - coordinate_columns = ("timestamp", "lat", "lon") - missing_coordinate_columns = [ - column for column in coordinate_columns if column not in df.columns - ] - if missing_coordinate_columns: - raise RuntimeError( - "Cannot write ERA5 NetCDF cube; missing coordinate column(s): " - f"{', '.join(missing_coordinate_columns)}" - ) - - null_coordinate_columns = [ - column for column in coordinate_columns if df[column].isna().any() - ] - if null_coordinate_columns: - raise RuntimeError( - "Cannot write ERA5 NetCDF cube; null coordinate value(s) found in: " - f"{', '.join(null_coordinate_columns)}" - ) - - input_row_count = int(len(df)) - duplicate_row_count = 0 - duplicate_group_count = 0 - duplicate_mask = df.duplicated(list(coordinate_columns), keep=False) - if duplicate_mask.any(): - duplicate_row_count = int(duplicate_mask.sum()) - duplicate_keys = df.loc[duplicate_mask, list(coordinate_columns)].drop_duplicates() - duplicate_group_count = int(len(duplicate_keys)) - examples = duplicate_keys.head(5).to_dict("records") - if duplicate_policy == "error": - raise RuntimeError( - "Cannot write ERA5 NetCDF cube; duplicate row(s) found for " - f"(timestamp, lat, lon): {duplicate_row_count}. Examples: {examples}" - ) - - print( - "Applying NetCDF duplicate policy " - f"{duplicate_policy!r}: {duplicate_row_count} row(s) across " - f"{duplicate_group_count} duplicate coordinate group(s)." - ) - if duplicate_policy in {"first", "last"}: - df = df.drop_duplicates( - list(coordinate_columns), - keep=duplicate_policy, - ignore_index=True, - ) - elif duplicate_policy == "mean": - aggregations = {} - for column in output_columns: - if column in coordinate_columns: - continue - if pd.api.types.is_numeric_dtype(df[column]): - aggregations[column] = "mean" - else: - aggregations[column] = "first" - df = df.groupby(list(coordinate_columns), as_index=False, sort=False).agg( - aggregations - ) - df = df.loc[:, output_columns] - else: - raise ValueError(f"Unsupported NetCDF duplicate policy: {duplicate_policy}") - - timestamps = np.sort(df["timestamp"].astype("int64").unique()) - latitudes = regularize_coordinate_axis( - np.sort(df["lat"].astype("float64").unique())[::-1], - descending=True, - np=np, - ) - longitudes = regularize_coordinate_axis( - np.sort(df["lon"].astype("float64").unique()), - descending=False, - np=np, - ) - - time_lookup = {value: index for index, value in enumerate(timestamps)} - lat_lookup = coordinate_lookup(latitudes) - lon_lookup = coordinate_lookup(longitudes) - - time_indices = df["timestamp"].map(time_lookup).to_numpy(dtype=np.int64) - lat_indices = map_coordinate_indices(df["lat"], lat_lookup, np) - lon_indices = map_coordinate_indices(df["lon"], lon_lookup, np) - - coords = { - "number": np.asarray(0, dtype=np.int64), - ERA5_TIME_DIM: ( - ERA5_TIME_DIM, - timestamp_to_valid_time_coordinate(timestamps, np, pd), - ), - "latitude": ("latitude", latitudes), - "longitude": ("longitude", longitudes), - "expver": (ERA5_TIME_DIM, np.full(len(timestamps), "0001", dtype=" float: - return round(float(value), COORDINATE_DECIMALS) - - -def coordinate_lookup(values) -> dict[float, int]: - return {coordinate_key(value): index for index, value in enumerate(values)} - - -def map_coordinate_indices(series, lookup: dict[float, int], np): - indices = [] - missing = [] - for value in series: - index = lookup.get(coordinate_key(value)) - if index is None: - missing.append(float(value)) - continue - indices.append(index) - - if missing: - examples = ", ".join(str(value) for value in missing[:5]) - raise RuntimeError( - "Cannot map coordinate value(s) onto the NetCDF grid. " - f"Examples: {examples}" - ) - return np.asarray(indices, dtype=np.int64) - - -def regularize_coordinate_axis(values, descending: bool, np): - values = np.asarray(values, dtype=np.float64) - if values.size < 3: - return values - - ascending_values = values[::-1] if descending else values - diffs = np.diff(ascending_values) - positive_diffs = diffs[diffs > 0] - if positive_diffs.size == 0: - return values - - rounded_diffs = np.round(positive_diffs, COORDINATE_DECIMALS) - unique_diffs, counts = np.unique(rounded_diffs, return_counts=True) - step = float(unique_diffs[np.argmax(counts)]) - if step <= 0 or not np.isfinite(step): - return values - - span = float(ascending_values[-1] - ascending_values[0]) - expected_count = int(round(span / step)) + 1 - if expected_count <= values.size: - return values - - coverage = values.size / expected_count - if coverage < REGULAR_GRID_MIN_COVERAGE: - return values - - regular_axis = np.round( - ascending_values[0] + np.arange(expected_count, dtype=np.float64) * step, - COORDINATE_DECIMALS, - ) - if descending: - regular_axis = regular_axis[::-1] - return regular_axis - - -def prediction_variable_attrs(column: str, latitudes, longitudes, np) -> dict[str, object]: - target = column.removeprefix("pred_").removeprefix("gt_") - long_name, units = PREDICTION_TARGET_METADATA.get( - target, - (column.replace("_", " "), "unknown"), - ) - if column.startswith("gt_"): - long_name = f"Ground truth {long_name.removeprefix('Predicted ').lower()}" - - lat_increment = coordinate_increment(latitudes, np) - lon_increment = coordinate_increment(longitudes, np) - attrs: dict[str, object] = { - "long_name": long_name, - "units": units, - "standard_name": "unknown", - "coordinates": ERA5_COORDINATES_ATTR, - "GRIB_dataType": "fc", - "GRIB_numberOfPoints": int(len(latitudes) * len(longitudes)), - "GRIB_stepType": "instant", - "GRIB_stepUnits": 1, - "GRIB_gridType": "regular_ll", - "GRIB_typeOfLevel": "surface", - "GRIB_uvRelativeToGrid": 0, - "GRIB_NV": 0, - "GRIB_cfName": "unknown", - "GRIB_cfVarName": column, - "GRIB_shortName": column, - "GRIB_gridDefinitionDescription": "Latitude/Longitude Grid", - "GRIB_iDirectionIncrementInDegrees": lon_increment, - "GRIB_iScansNegatively": 0, - "GRIB_jDirectionIncrementInDegrees": lat_increment, - "GRIB_jPointsAreConsecutive": 0, - "GRIB_jScansPositively": 0, - "GRIB_latitudeOfFirstGridPointInDegrees": float(latitudes[0]) if len(latitudes) else np.nan, - "GRIB_latitudeOfLastGridPointInDegrees": float(latitudes[-1]) if len(latitudes) else np.nan, - "GRIB_longitudeOfFirstGridPointInDegrees": float(longitudes[0]) if len(longitudes) else np.nan, - "GRIB_longitudeOfLastGridPointInDegrees": float(longitudes[-1]) if len(longitudes) else np.nan, - "GRIB_Nx": int(len(longitudes)), - "GRIB_Ny": int(len(latitudes)), - "GRIB_missingValue": float(np.finfo(np.float32).max), - "GRIB_name": long_name, - "GRIB_totalNumber": 0, - "GRIB_units": units, - "GRIB_surface": 0.0, - } - return attrs - - -def coordinate_increment(values, np) -> float: - if len(values) < 2: - return float("nan") - diffs = np.diff(np.asarray(values, dtype=np.float64)) - return float(abs(np.nanmedian(diffs))) - - -def era5_netcdf_encoding(dataset, np) -> dict[str, dict[str, object]]: - encoding: dict[str, dict[str, object]] = { - "number": {"dtype": "int64"}, - "latitude": {"dtype": "float64", "_FillValue": np.nan}, - "longitude": {"dtype": "float64", "_FillValue": np.nan}, - } - if np.issubdtype(dataset[ERA5_TIME_DIM].dtype, np.datetime64): - encoding[ERA5_TIME_DIM] = { - "dtype": "int64", - "units": "seconds since 1970-01-01", - "calendar": "proleptic_gregorian", - } - else: - encoding[ERA5_TIME_DIM] = {"dtype": "int64"} - - if all(dim in dataset.sizes for dim in ERA5_CUBE_DIMS): - cube_chunks = era5_cube_chunks( - tuple(int(dataset.sizes[dim]) for dim in ERA5_CUBE_DIMS) - ) - else: - cube_chunks = None - - for name, variable in dataset.data_vars.items(): - if variable.dtype.kind in {"f", "i", "u"}: - variable_encoding: dict[str, object] = { - "zlib": True, - "complevel": 1, - "shuffle": True, - } - if variable.dtype.kind == "f": - variable_encoding["dtype"] = str(variable.dtype) - variable_encoding["_FillValue"] = ( - np.float32(np.nan) if variable.dtype == np.float32 else np.nan - ) - if variable.dims == ERA5_CUBE_DIMS and cube_chunks is not None: - variable_encoding["chunksizes"] = cube_chunks - elif variable.dims == ERA5_SPATIAL_DIMS: - variable_encoding["chunksizes"] = cube_chunks[1:] if cube_chunks is not None else None - encoding[name] = { - key: value - for key, value in variable_encoding.items() - if value is not None - } - return encoding - - -def era5_cube_chunks(shape: tuple[int, int, int]) -> tuple[int, int, int]: - return tuple( - max(1, min(size, target)) - for size, target in zip(shape, ERA5_CHUNK_TARGETS) - ) - - -def timestamp_to_valid_time_coordinate(timestamps, np, pd): - timestamp_text = pd.Series(timestamps).astype("int64").astype(str) - lengths = set(timestamp_text.str.len()) - if lengths == {14}: - try: - return pd.to_datetime( - timestamp_text, - format="%Y%m%d%H%M%S", - errors="raise", - ).to_numpy(dtype="datetime64[ns]") - except ValueError: - pass - if lengths == {8}: - try: - return pd.to_datetime( - timestamp_text, - format="%Y%m%d", - errors="raise", - ).to_numpy(dtype="datetime64[ns]") - except ValueError: - pass - - return np.asarray(timestamps, dtype=np.int64) - - -def add_igbp_variable( - data_vars, - df, - cube_shape, - lat_indices, - lon_indices, - time_indices, - lat_lookup, - lon_lookup, - np, -): - igbp_values = df["igbp"].fillna("").astype(str) - unique_by_cell = ( - df.assign(igbp=igbp_values) - .groupby(["lat", "lon"], sort=False)["igbp"] - .nunique(dropna=False) - ) - igbp_is_static = bool((unique_by_cell <= 1).all()) - - if igbp_is_static: - array = np.full(cube_shape[1:], "", dtype=" Path: - output_path = args.output_path or args.output_csv - if output_path is None: - raise ValueError("Provide --output-path, or --output-csv for CSV-compatible usage.") - return output_path.expanduser().resolve() - - -def main(): - args = parse_args() - shard_dir = args.shard_dir.expanduser().resolve() - output_path = resolve_output_path(args) - shard_paths = resolve_shard_paths(shard_dir, args.num_shards) - prediction_targets = parse_prediction_targets(args.prediction_targets) - - print(f"Merging {len(shard_paths)} shard(s) from {shard_dir}") - if prediction_targets is not None: - print(f"Prediction targets: {', '.join(prediction_targets)}") - if args.sort_output: - print( - f"Sorting output by {INTERNAL_ORDER_COLUMN} when present; " - f"fallback: {', '.join(DEFAULT_SORT_COLUMNS)}" - ) - if args.format == "csv": - merge_csv_shards( - shard_paths, - output_path, - prediction_targets, - sort_output=args.sort_output, - sort_tmp_dir=args.sort_tmp_dir, - ) - print(f"Saved merged CSV to {output_path}") - else: - write_netcdf_from_csv_shards( - shard_paths, - output_path, - prediction_targets, - sort_output=args.sort_output, - duplicate_policy=args.netcdf_duplicate_policy, - ) - print(f"Saved NetCDF to {output_path}") - - if args.cleanup: - shutil.rmtree(shard_dir) - print(f"Removed shard directory {shard_dir}") - - -if __name__ == "__main__": - main() diff --git a/eval/era5/test_era5_multi_gpu.py b/eval/era5/test_era5_multi_gpu.py index cd726d7..97e3ffd 100644 --- a/eval/era5/test_era5_multi_gpu.py +++ b/eval/era5/test_era5_multi_gpu.py @@ -48,7 +48,7 @@ def __call__(self, batch): def parse_args(): parser = argparse.ArgumentParser( - description="Run distributed ERA5 inference with torchrun and save predictions to CSV." + description="Run distributed ERA5 inference with torchrun and write rank CSV shards." ) parser.add_argument( "--run-path", @@ -71,35 +71,13 @@ def parse_args(): "(default: run_path/last.pth, else latest checkpoint-*.pth)." ), ) - parser.add_argument( - "--output-csv", - type=Path, - default=None, - help=( - "Output CSV path. Default is /eval/era5_predictions_multi_gpu.csv, " - "or era5_predictions__to__multi_gpu.csv when date bounds are provided." - ), - ) parser.add_argument( "--shard-dir", type=Path, default=None, help=( - "Directory for per-rank temporary CSV shards " - "(default: /._shards)." - ), - ) - parser.add_argument( - "--keep-shards", - action="store_true", - help="Keep per-rank shard CSVs after rank 0 merges them.", - ) - parser.add_argument( - "--skip-merge", - action="store_true", - help=( - "Only write per-rank shard CSVs. Use eval/era5/merge_era5_shards.py " - "or scripts/era5/merge_prediction_shards.sh to merge them later." + "Directory for per-rank CSV shards " + "(default: /eval/.era5_predictions__multi_gpu_shards)." ), ) parser.add_argument( @@ -173,7 +151,7 @@ def parse_args(): default=None, metavar="TARGET", help=( - "Prediction target columns to include in the output CSV. Accepts " + "Prediction target columns to include in the output shards. Accepts " "comma-separated or space-separated values, with or without the " "pred_ prefix. Default: all model outputs." ), @@ -245,11 +223,11 @@ def barrier(distributed: bool, device=None): dist.barrier() -def default_output_csv_path(run_path: Path, date_tag: str | None) -> Path: +def default_shard_dir_path(run_path: Path, date_tag: str | None) -> Path: filename = ( - "era5_predictions_multi_gpu.csv" + ".era5_predictions_multi_gpu_shards" if date_tag is None - else f"era5_predictions_{date_tag}_multi_gpu.csv" + else f".era5_predictions_{date_tag}_multi_gpu_shards" ) return run_path / "eval" / filename @@ -363,18 +341,6 @@ def prepare_shard_dir(shard_dir: Path, *, rank: int, distributed: bool, device): barrier(distributed, device) -def merge_csv_shards(shard_paths: list[Path], output_csv_path: Path): - from eval.era5.merge_era5_shards import merge_csv_shards as merge_post_csv_shards - - merge_post_csv_shards( - shard_paths, - output_csv_path, - prediction_targets=None, - sort_output=True, - sort_tmp_dir=None, - ) - - def move_batch_to_device(batch, device): batch.predictor_values = batch.predictor_values.to(device, non_blocking=True) batch.aux_values = batch.aux_values.to(device, non_blocking=True) @@ -458,19 +424,14 @@ def main(): db_path = args.db_path.expanduser().resolve() if not db_path.exists(): raise FileNotFoundError(f"SQLite database not found: {db_path}") - output_csv_path = ( - default_output_csv_path(run_path, date_tag) - if args.output_csv is None - else args.output_csv.resolve() - ) shard_dir = ( args.shard_dir.expanduser().resolve() if args.shard_dir is not None - else output_csv_path.parent / f".{output_csv_path.stem}_shards" + else default_shard_dir_path(run_path, date_tag) ) if rank == 0: - output_csv_path.parent.mkdir(parents=True, exist_ok=True) + shard_dir.parent.mkdir(parents=True, exist_ok=True) prepare_shard_dir(shard_dir, rank=rank, distributed=distributed, device=device) with config_path.open("r", encoding="utf-8") as f: @@ -497,7 +458,6 @@ def main(): print(f"Data path: {data_path}") print(f"DB path: {db_path}") print(f"Torchrun world size: {world_size}") - print(f"Output CSV: {output_csv_path}") print(f"Shard dir: {shard_dir}") if start_timestamp is not None or end_timestamp is not None: print(f"ERA5 date filter: {start_timestamp or 'start'} to {end_timestamp or 'end'}") @@ -643,14 +603,6 @@ def main(): flush=True, ) - if args.skip_merge: - if rank == 0: - print(f"Skipped final merge. Rank shards are written under: {shard_dir}") - print(f"Merge later to: {output_csv_path}") - if distributed: - dist.destroy_process_group() - return - stats_device = device if device.type == "cuda" else torch.device("cpu") stats = torch.tensor( [rows_written, batches_processed, low_solar_gpp_rows], @@ -665,13 +617,10 @@ def main(): int(stats[2].item()), ) - barrier(distributed, device) if rank == 0: - shard_paths = [shard_dir / f"rank_{shard_rank:05d}.csv" for shard_rank in range(world_size)] - merge_csv_shards(shard_paths, output_csv_path) print( - f"Saved predictions for {total_rows_written} samples across " - f"{total_batches_processed} batches to: {output_csv_path}" + f"Saved rank shards for {total_rows_written} samples across " + f"{total_batches_processed} batches under: {shard_dir}" ) if force_zero_gpp_low_solar: print( @@ -679,8 +628,6 @@ def main(): f"raw final SW_IN < {args.gpp_solar_threshold:g} W m-2 " f"(normalized SW_IN < {normalized_gpp_solar_threshold:.6g})" ) - if not args.skip_merge and not args.keep_shards: - shutil.rmtree(shard_dir) barrier(distributed, device) if distributed: diff --git a/scripts/era5/build_year_nc.sh b/scripts/era5/build_year_nc.sh index f46fb9c..a1ae34d 100755 --- a/scripts/era5/build_year_nc.sh +++ b/scripts/era5/build_year_nc.sh @@ -3,7 +3,19 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +REPO_ROOT="" +for candidate in "$PWD" "${SLURM_SUBMIT_DIR:-}" "${SLURM_SUBMIT_DIR:+$SLURM_SUBMIT_DIR/../..}" "$SCRIPT_DIR/../.."; do + [[ -n "$candidate" ]] || continue + candidate="$(cd "$candidate" 2>/dev/null && pwd)" || continue + if [[ -f "$candidate/setup.py" && -d "$candidate/scripts/era5" ]]; then + REPO_ROOT="$candidate" + break + fi +done +[[ -n "$REPO_ROOT" ]] || { + echo "Could not locate EcoPerceiver repo root." >&2 + exit 2 +} cd "$REPO_ROOT" if [[ -n "${ECOPERCEIVER_ENV:-}" && -f "$ECOPERCEIVER_ENV/bin/activate" ]]; then @@ -15,276 +27,4 @@ fi export PYTHONUNBUFFERED=1 -python3 - "$@" <<'PY' -import argparse -import os -from datetime import datetime, timezone -from pathlib import Path -import sys - - -DEFAULT_RUN_PATH = ( - "experiments/runs/" - "final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" -) -TIME_DIM = "valid_time" -SPATIAL_DIMS = ("latitude", "longitude") -CUBE_DIMS = (TIME_DIM, *SPATIAL_DIMS) -CHUNK_TARGETS = (186, 31, 360) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="build_year_nc.sh", - description=( - "Unify four quarterly EcoPerceiver ERA5 NetCDF outputs into one " - "calendar-year NetCDF file." - ) - ) - parser.add_argument("year", nargs="?", help="Four-digit year to assemble.") - parser.add_argument("--year", dest="year_option", help="Four-digit year to assemble.") - parser.add_argument( - "--run-path", - default=os.environ.get("RUN_PATH", DEFAULT_RUN_PATH), - help="EcoPerceiver run directory. Default: RUN_PATH env or the repo default run.", - ) - parser.add_argument( - "--input-dir", - default=os.environ.get("INPUT_DIR"), - help="Directory containing quarter .nc files. Default: /eval.", - ) - parser.add_argument( - "--output-path", - default=os.environ.get("OUTPUT_PATH"), - help="Yearly NetCDF output path. Default: /era5_predictions_.nc.", - ) - parser.add_argument( - "--engine", - default=os.environ.get("XARRAY_ENGINE", "h5netcdf"), - help="xarray NetCDF engine. Default: h5netcdf.", - ) - parser.add_argument( - "--inputs", - nargs=4, - metavar="PATH", - help=( - "Explicit Q1 Q2 Q3 Q4 NetCDF paths. If omitted, paths are inferred " - "from the quarterly post-processing output naming convention." - ), - ) - parser.add_argument( - "--allow-missing", - action="store_true", - help="Use the existing quarter files and skip missing inferred inputs.", - ) - parser.add_argument( - "--overwrite", - action="store_true", - help="Replace an existing output file.", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print input and output paths without writing the assembled file.", - ) - args = parser.parse_args() - - year = args.year_option or args.year - if year is None: - parser.error("YEAR is required as a positional argument or --year.") - if not year.isdigit() or len(year) != 4: - parser.error(f"YEAR must be a four-digit year, got: {year}") - args.year = year - return args - - -def quarter_paths(year: str, input_dir: Path) -> list[Path]: - ranges = ( - (f"{year}-01-01", f"{year}-03-31"), - (f"{year}-04-01", f"{year}-06-30"), - (f"{year}-07-01", f"{year}-09-30"), - (f"{year}-10-01", f"{year}-12-31"), - ) - paths = [] - for start, end in ranges: - date_tag = f"{start.replace('-', '')}_to_{end.replace('-', '')}" - paths.append(input_dir / f"era5_predictions_{date_tag}.nc") - return paths - - -def resolve_paths(args: argparse.Namespace) -> tuple[list[Path], Path]: - run_path = Path(args.run_path).expanduser() - input_dir = Path(args.input_dir).expanduser() if args.input_dir else run_path / "eval" - if args.inputs: - inputs = [Path(path).expanduser() for path in args.inputs] - else: - inputs = quarter_paths(args.year, input_dir) - - output_path = ( - Path(args.output_path).expanduser() - if args.output_path - else input_dir / f"era5_predictions_{args.year}.nc" - ) - return inputs, output_path - - -def existing_inputs(inputs: list[Path], allow_missing: bool) -> list[Path]: - missing = [path for path in inputs if not path.is_file()] - if missing and not allow_missing: - missing_list = "\n ".join(str(path) for path in missing) - raise FileNotFoundError(f"Missing quarter NetCDF input(s):\n {missing_list}") - - present = [path for path in inputs if path.is_file()] - if not present: - raise FileNotFoundError("No quarter NetCDF inputs found.") - if allow_missing and len(present) < len(inputs): - print(f"Skipping {len(inputs) - len(present)} missing quarter input(s).") - return present - - -def has_duplicate_times(dataset, pandas) -> bool: - if TIME_DIM not in dataset.coords: - raise RuntimeError(f"Assembled dataset is missing required coordinate: {TIME_DIM}") - index = dataset.indexes.get(TIME_DIM) - if index is None: - index = pandas.Index(dataset[TIME_DIM].values) - return bool(index.has_duplicates) - - -def build_encoding(dataset, numpy) -> dict[str, dict[str, object]]: - encoding: dict[str, dict[str, object]] = {} - - if TIME_DIM in dataset.variables: - if numpy.issubdtype(dataset[TIME_DIM].dtype, numpy.datetime64): - encoding[TIME_DIM] = { - "dtype": "int64", - "units": "seconds since 1970-01-01", - "calendar": "proleptic_gregorian", - } - else: - encoding[TIME_DIM] = {"dtype": "int64"} - - for coord in SPATIAL_DIMS: - if coord in dataset.variables: - encoding[coord] = {"dtype": "float64", "_FillValue": numpy.nan} - - cube_chunks = None - if all(dim in dataset.sizes for dim in CUBE_DIMS): - cube_chunks = tuple( - max(1, min(int(dataset.sizes[dim]), target)) - for dim, target in zip(CUBE_DIMS, CHUNK_TARGETS) - ) - - for name, variable in dataset.data_vars.items(): - if variable.dtype.kind not in {"f", "i", "u"}: - continue - - variable_encoding: dict[str, object] = { - "zlib": True, - "complevel": 1, - "shuffle": True, - } - if variable.dtype.kind == "f": - variable_encoding["dtype"] = str(variable.dtype) - variable_encoding["_FillValue"] = ( - numpy.float32(numpy.nan) if variable.dtype == numpy.float32 else numpy.nan - ) - if variable.dims == CUBE_DIMS and cube_chunks is not None: - variable_encoding["chunksizes"] = cube_chunks - elif variable.dims == SPATIAL_DIMS and cube_chunks is not None: - variable_encoding["chunksizes"] = cube_chunks[1:] - encoding[name] = variable_encoding - - return encoding - - -def assemble_year(inputs: list[Path], output_path: Path, engine: str, overwrite: bool) -> None: - try: - import numpy as np - import pandas as pd - import xarray as xr - except ImportError as exc: - raise RuntimeError( - "Merging NetCDF files requires numpy, pandas, and xarray in the active environment." - ) from exc - - if output_path.exists() and not overwrite: - raise FileExistsError(f"Output already exists. Use --overwrite to replace it: {output_path}") - - output_path.parent.mkdir(parents=True, exist_ok=True) - datasets = [] - try: - for path in inputs: - ds = xr.open_dataset(path, engine=engine) - if TIME_DIM not in ds.dims and TIME_DIM not in ds.coords: - raise RuntimeError(f"{path} is missing required time dimension: {TIME_DIM}") - datasets.append(ds) - - assembled = xr.concat( - datasets, - dim=TIME_DIM, - data_vars="minimal", - coords="minimal", - compat="override", - join="exact", - combine_attrs="override", - ) - assembled = assembled.sortby(TIME_DIM) - if has_duplicate_times(assembled, pd): - raise RuntimeError( - f"Duplicate {TIME_DIM} coordinate values found after assembly. " - "Check for overlapping quarter files." - ) - - history = assembled.attrs.get("history", "") - assembly_note = ( - f"{datetime.now(timezone.utc).isoformat()} assembled quarterly " - "EcoPerceiver ERA5 NetCDF outputs into one calendar-year file" - ) - assembled.attrs["history"] = f"{history}\n{assembly_note}".strip() - assembled.attrs["assembled_input_files"] = " ".join(str(path) for path in inputs) - assembled.attrs["num_assembled_input_files"] = len(inputs) - - tmp_path = output_path.with_suffix(output_path.suffix + ".tmp") - tmp_path.unlink(missing_ok=True) - try: - assembled.to_netcdf(tmp_path, engine=engine, encoding=build_encoding(assembled, np)) - tmp_path.replace(output_path) - finally: - tmp_path.unlink(missing_ok=True) - assembled.close() - finally: - for dataset in datasets: - dataset.close() - - -def main() -> int: - args = parse_args() - inputs, output_path = resolve_paths(args) - if not args.dry_run: - inputs = existing_inputs(inputs, args.allow_missing) - - print("ERA5 yearly NetCDF assembly") - print(f"Year: {args.year}") - print("Inputs:") - for path in inputs: - print(f" {path}") - print(f"Output: {output_path}") - print(f"Engine: {args.engine}") - - if args.dry_run: - print("Dry run only; no file written.") - return 0 - - assemble_year(inputs, output_path, args.engine, args.overwrite) - print(f"Saved yearly NetCDF to {output_path}") - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except Exception as exc: - print(f"ERROR: {exc}", file=sys.stderr) - raise SystemExit(1) -PY +python3 -u eval/era5/build_year_nc_from_shards.py "$@" diff --git a/scripts/era5/merge_prediction_shards.sh b/scripts/era5/merge_prediction_shards.sh deleted file mode 100755 index d3c85e7..0000000 --- a/scripts/era5/merge_prediction_shards.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/bin/bash -#SBATCH --nodes=1 -#SBATCH --mem=256G -#SBATCH --cpus-per-task=4 -#SBATCH --time=12:00:00 -#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/merge_prediction_shards.out -#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/merge_prediction_shards.error -#SBATCH --open-mode=truncate -#SBATCH --job-name=merge-shards -#SBATCH --account=aip-pal - -set -euo pipefail - -source "$SCRATCH/env/ecoperceiver/bin/activate" -cd ~/links/scratch/EcoPerceiver - -export PYTHONUNBUFFERED=1 - -usage() { - echo "Usage: $0 [--format csv|netcdf] [--prediction-targets TARGET ...] [--sort-output|--no-sort-output] [--netcdf-duplicate-policy error|first|last|mean]" >&2 -} - -append_prediction_targets() { - local raw value - raw="${1//,/ }" - for value in $raw; do - if [[ -n "$value" ]]; then - PREDICTION_TARGET_LIST+=("$value") - fi - done -} - -POST_FORMAT="${POST_FORMAT:-netcdf}" -SORT_OUTPUT="${SORT_OUTPUT:-1}" -NUM_SHARDS="${NUM_SHARDS:-4}" -NETCDF_DUPLICATE_POLICY="${NETCDF_DUPLICATE_POLICY:-last}" -PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" -PREDICTION_TARGET_LIST=() -if [[ -n "$PREDICTION_TARGETS_ENV" ]]; then - append_prediction_targets "$PREDICTION_TARGETS_ENV" -fi -while [[ $# -gt 0 ]]; do - case "$1" in - --format) - if [[ $# -lt 2 ]]; then - echo "--format requires a value." >&2 - usage - exit 2 - fi - POST_FORMAT="$2" - shift 2 - ;; - --format=*) - POST_FORMAT="${1#*=}" - shift - ;; - --prediction-targets) - shift - PREDICTION_TARGET_LIST=() - while [[ $# -gt 0 && "$1" != --* ]]; do - append_prediction_targets "$1" - shift - done - if [[ "${#PREDICTION_TARGET_LIST[@]}" -eq 0 ]]; then - echo "--prediction-targets requires at least one target." >&2 - usage - exit 2 - fi - ;; - --prediction-targets=*) - PREDICTION_TARGET_LIST=() - append_prediction_targets "${1#*=}" - if [[ "${#PREDICTION_TARGET_LIST[@]}" -eq 0 ]]; then - echo "--prediction-targets requires at least one target." >&2 - usage - exit 2 - fi - shift - ;; - --sort-output) - SORT_OUTPUT=1 - shift - ;; - --no-sort-output) - SORT_OUTPUT=0 - shift - ;; - --netcdf-duplicate-policy) - if [[ $# -lt 2 ]]; then - echo "--netcdf-duplicate-policy requires a value." >&2 - usage - exit 2 - fi - NETCDF_DUPLICATE_POLICY="$2" - shift 2 - ;; - --netcdf-duplicate-policy=*) - NETCDF_DUPLICATE_POLICY="${1#*=}" - shift - ;; - csv|netcdf) - POST_FORMAT="$1" - shift - ;; - *) - echo "Unknown argument: $1" >&2 - usage - exit 2 - ;; - esac -done - -case "$POST_FORMAT" in - csv) - OUTPUT_EXT="csv" - ;; - netcdf) - OUTPUT_EXT="nc" - ;; - *) - echo "POST_FORMAT must be csv or netcdf, got: $POST_FORMAT" >&2 - exit 2 - ;; -esac - -case "$NETCDF_DUPLICATE_POLICY" in - error|first|last|mean) - ;; - *) - echo "NETCDF_DUPLICATE_POLICY must be error, first, last, or mean; got: $NETCDF_DUPLICATE_POLICY" >&2 - exit 2 - ;; -esac - -if [[ ! "$NUM_SHARDS" =~ ^[0-9]+$ || "$NUM_SHARDS" -lt 1 ]]; then - echo "NUM_SHARDS must be a positive integer, got: $NUM_SHARDS" >&2 - exit 2 -fi - -case "${SORT_OUTPUT,,}" in - 0|false|no) - SORT_ARGS=() - SORT_LABEL="false" - ;; - 1|true|yes) - SORT_ARGS=(--sort-output) - SORT_LABEL="true" - ;; - *) - echo "SORT_OUTPUT must be 0/1, false/true, or no/yes; got: $SORT_OUTPUT" >&2 - exit 2 - ;; -esac - -if [[ -n "${SORT_TMP_DIR:-}" ]]; then - SORT_ARGS+=(--sort-tmp-dir "$SORT_TMP_DIR") -fi - -PREDICTION_TARGET_ARGS=() -if [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]]; then - PREDICTION_TARGET_ARGS=(--prediction-targets "${PREDICTION_TARGET_LIST[@]}") - PREDICTION_TARGET_LABEL="${PREDICTION_TARGET_LIST[*]}" -else - PREDICTION_TARGET_LABEL="" -fi - -RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" -INITIAL_DATE="${INITIAL_DATE:-2017-06-01}" -FINAL_DATE="${FINAL_DATE:-2017-06-30}" -DATE_TAG="${INITIAL_DATE//-/}_to_${FINAL_DATE//-/}" -OUTPUT_PATH="${OUTPUT_PATH:-$RUN_PATH/eval/era5_predictions_${DATE_TAG}.${OUTPUT_EXT}}" -SHARD_DIR="${SHARD_DIR:-$RUN_PATH/eval/.era5_predictions_${DATE_TAG}_multi_gpu_shards}" - -echo "[$(date)] Starting prediction-shard merge job ${SLURM_JOB_ID:-local}" -echo "Format: $POST_FORMAT" -echo "Date window: $INITIAL_DATE to $FINAL_DATE" -echo "Shard dir: $SHARD_DIR" -echo "Output path: $OUTPUT_PATH" -echo "Num shards: $NUM_SHARDS" -echo "Prediction targets: $PREDICTION_TARGET_LABEL" -echo "Sort output: $SORT_LABEL" -echo "NetCDF duplicate policy: $NETCDF_DUPLICATE_POLICY" -echo "Temporary shard order key: __sample_order (dropped from final output when present)" - -python3 -u eval/era5/merge_era5_shards.py \ - --format "$POST_FORMAT" \ - --shard-dir "$SHARD_DIR" \ - --output-path "$OUTPUT_PATH" \ - --num-shards "$NUM_SHARDS" \ - "${SORT_ARGS[@]}" \ - --netcdf-duplicate-policy "$NETCDF_DUPLICATE_POLICY" \ - "${PREDICTION_TARGET_ARGS[@]}" - # --cleanup diff --git a/scripts/era5/post_processing.sh b/scripts/era5/post_processing.sh new file mode 100755 index 0000000..ee21e95 --- /dev/null +++ b/scripts/era5/post_processing.sh @@ -0,0 +1,623 @@ +#!/bin/bash +#SBATCH --nodes=1 +#SBATCH --mem=480G +#SBATCH --cpus-per-task=16 +#SBATCH --time=12:00:00 +#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/post_processing.out +#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/post_processing.error +#SBATCH --open-mode=truncate +#SBATCH --job-name=post-era5 +#SBATCH --account=aip-pal +#SBATCH --partition=cpubase_bycore_b3 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="" +for candidate in "$PWD" "${SLURM_SUBMIT_DIR:-}" "${SLURM_SUBMIT_DIR:+$SLURM_SUBMIT_DIR/../..}" "$SCRIPT_DIR/../.."; do + [[ -n "$candidate" ]] || continue + candidate="$(cd "$candidate" 2>/dev/null && pwd)" || continue + if [[ -f "$candidate/setup.py" && -d "$candidate/scripts/era5" ]]; then + REPO_ROOT="$candidate" + break + fi +done +[[ -n "$REPO_ROOT" ]] || { + echo "Could not locate EcoPerceiver repo root." >&2 + exit 2 +} +cd "$REPO_ROOT" + +if [[ -f "$REPO_ROOT/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$REPO_ROOT/.env" + set +a +fi + +if [[ -n "${ECOPERCEIVER_ENV:-}" && -f "$ECOPERCEIVER_ENV/bin/activate" ]]; then + source "$ECOPERCEIVER_ENV/bin/activate" +elif [[ -n "${SCRATCH:-}" && -f "$SCRATCH/env/ecoperceiver/bin/activate" ]]; then + source "$SCRATCH/env/ecoperceiver/bin/activate" +fi + +export PYTHONUNBUFFERED=1 + +DEFAULT_RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" + +usage() { + cat >&2 <&2 + usage + exit 2 +} + +append_prediction_targets() { + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + PREDICTION_TARGET_LIST+=("$value") + fi + done +} + +is_positive_int() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +default_shard_dirs() { + local year="$1" + local input_dir="$2" + SHARD_DIRS=( + "$input_dir/.era5_predictions_${year}0101_to_${year}0331_multi_gpu_shards" + "$input_dir/.era5_predictions_${year}0401_to_${year}0630_multi_gpu_shards" + "$input_dir/.era5_predictions_${year}0701_to_${year}0930_multi_gpu_shards" + "$input_dir/.era5_predictions_${year}1001_to_${year}1231_multi_gpu_shards" + ) +} + +validate_shard_inputs() { + local errors=0 + local expected_header="" + local dir file header rank rank_name actual_count + local files=() + + shopt -s nullglob + for dir in "${SHARD_DIRS[@]}"; do + if [[ ! -d "$dir" ]]; then + echo "Missing shard directory: $dir" >&2 + errors=1 + continue + fi + + files=("$dir"/rank_*.csv) + actual_count="${#files[@]}" + if [[ "$actual_count" -ne "$NUM_SHARDS" ]]; then + echo "Expected $NUM_SHARDS rank_*.csv files in $dir, found $actual_count." >&2 + errors=1 + fi + + for ((rank = 0; rank < NUM_SHARDS; rank++)); do + printf -v rank_name "rank_%05d.csv" "$rank" + file="$dir/$rank_name" + if [[ ! -f "$file" ]]; then + echo "Missing shard file: $file" >&2 + errors=1 + continue + fi + if ! IFS= read -r header < "$file"; then + echo "Shard file is empty: $file" >&2 + errors=1 + continue + fi + header="${header%$'\r'}" + if [[ -z "$header" ]]; then + echo "Shard file has an empty header: $file" >&2 + errors=1 + continue + fi + if [[ -z "$expected_header" ]]; then + expected_header="$header" + elif [[ "$header" != "$expected_header" ]]; then + echo "CSV header mismatch in shard file: $file" >&2 + errors=1 + fi + done + done + shopt -u nullglob + + return "$errors" +} + +wait_for_shards() { + local attempt=1 + while ! validate_shard_inputs; do + if (( attempt >= SHARD_READY_RETRIES )); then + echo "Shard validation failed after $attempt attempt(s); refusing to build yearly NetCDF." >&2 + return 1 + fi + echo "Shard inputs are not complete yet; retrying in ${SHARD_READY_SLEEP_SECONDS}s ($attempt/$SHARD_READY_RETRIES)." + sleep "$SHARD_READY_SLEEP_SECONDS" + attempt=$((attempt + 1)) + done +} + +YEAR="${YEAR:-}" +RUN_PATH="${RUN_PATH:-$DEFAULT_RUN_PATH}" +INPUT_DIR="${INPUT_DIR:-}" +OUTPUT_PATH="${OUTPUT_PATH:-}" +NUM_SHARDS="${NUM_SHARDS:-4}" +POST_PROCESS_PREDICTION_TARGETS_ENV="${POST_PROCESS_PREDICTION_TARGETS:-${PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}}" +PREDICTION_TARGET_LIST=() +append_prediction_targets "$POST_PROCESS_PREDICTION_TARGETS_ENV" +NETCDF_DUPLICATE_POLICY="${NETCDF_DUPLICATE_POLICY:-last}" +CHUNK_ROWS="${BUILD_YEAR_CHUNK_ROWS:-}" +WRITE_TIME_CHUNK="${BUILD_YEAR_WRITE_TIME_CHUNK:-}" +MAX_MEMORY_GB="${BUILD_YEAR_MAX_MEMORY_GB:-470}" +OVERWRITE=0 +DRY_RUN=0 +PUSH_TARGET="${PUSH_TARGET:-drive}" +DESTINATION_PATH="${DESTINATION_PATH:-}" +HF_REPO_ID="${HF_REPO_ID:-}" +PATH_IN_REPO="${PATH_IN_REPO:-}" +REVISION="${REVISION:-${HF_REVISION:-}}" +COMMIT_MESSAGE="${HF_COMMIT_MESSAGE:-}" +CREATE_REPO="${CREATE_REPO:-1}" +PRIVATE="${PRIVATE:-0}" +RCLONE_REMOTE="${RCLONE_REMOTE:-}" +DRIVE_DIR="${DRIVE_DIR:-}" +REMOTE_DIR="${REMOTE_DIR:-}" +REMOTE_PATH="${REMOTE_PATH:-}" +RCLONE_ARGS=() +SHARD_READY_RETRIES="${SHARD_READY_RETRIES:-10}" +SHARD_READY_SLEEP_SECONDS="${SHARD_READY_SLEEP_SECONDS:-60}" +SHARD_DIRS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --year) + [[ $# -ge 2 ]] || die "--year requires a value." + YEAR="$2" + shift 2 + ;; + --year=*) + YEAR="${1#*=}" + shift + ;; + --run-path) + [[ $# -ge 2 ]] || die "--run-path requires a value." + RUN_PATH="$2" + shift 2 + ;; + --run-path=*) + RUN_PATH="${1#*=}" + shift + ;; + --input-dir) + [[ $# -ge 2 ]] || die "--input-dir requires a value." + INPUT_DIR="$2" + shift 2 + ;; + --input-dir=*) + INPUT_DIR="${1#*=}" + shift + ;; + --output-path) + [[ $# -ge 2 ]] || die "--output-path requires a value." + OUTPUT_PATH="$2" + shift 2 + ;; + --output-path=*) + OUTPUT_PATH="${1#*=}" + shift + ;; + --shard-dirs) + [[ $# -ge 5 ]] || die "--shard-dirs requires four paths." + SHARD_DIRS=("$2" "$3" "$4" "$5") + shift 5 + ;; + --num-shards) + [[ $# -ge 2 ]] || die "--num-shards requires a value." + NUM_SHARDS="$2" + shift 2 + ;; + --num-shards=*) + NUM_SHARDS="${1#*=}" + shift + ;; + --prediction-targets) + shift + PREDICTION_TARGET_LIST=() + while [[ $# -gt 0 && "$1" != --* ]]; do + append_prediction_targets "$1" + shift + done + [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." + ;; + --prediction-targets=*) + PREDICTION_TARGET_LIST=() + append_prediction_targets "${1#*=}" + [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." + shift + ;; + --netcdf-duplicate-policy) + [[ $# -ge 2 ]] || die "--netcdf-duplicate-policy requires a value." + NETCDF_DUPLICATE_POLICY="$2" + shift 2 + ;; + --netcdf-duplicate-policy=*) + NETCDF_DUPLICATE_POLICY="${1#*=}" + shift + ;; + --chunk-rows) + [[ $# -ge 2 ]] || die "--chunk-rows requires a value." + CHUNK_ROWS="$2" + shift 2 + ;; + --chunk-rows=*) + CHUNK_ROWS="${1#*=}" + shift + ;; + --write-time-chunk) + [[ $# -ge 2 ]] || die "--write-time-chunk requires a value." + WRITE_TIME_CHUNK="$2" + shift 2 + ;; + --write-time-chunk=*) + WRITE_TIME_CHUNK="${1#*=}" + shift + ;; + --max-memory-gb) + [[ $# -ge 2 ]] || die "--max-memory-gb requires a value." + MAX_MEMORY_GB="$2" + shift 2 + ;; + --max-memory-gb=*) + MAX_MEMORY_GB="${1#*=}" + shift + ;; + --overwrite) + OVERWRITE=1 + shift + ;; + --to|--target|--push-target) + [[ $# -ge 2 ]] || die "$1 requires a value." + PUSH_TARGET="$2" + shift 2 + ;; + --to=*|--target=*|--push-target=*) + PUSH_TARGET="${1#*=}" + shift + ;; + --path) + [[ $# -ge 2 ]] || die "--path requires a value." + DESTINATION_PATH="$2" + shift 2 + ;; + --path=*) + DESTINATION_PATH="${1#*=}" + shift + ;; + --hf-repo-id) + [[ $# -ge 2 ]] || die "--hf-repo-id requires a value." + HF_REPO_ID="$2" + shift 2 + ;; + --hf-repo-id=*) + HF_REPO_ID="${1#*=}" + shift + ;; + --path-in-repo) + [[ $# -ge 2 ]] || die "--path-in-repo requires a value." + PATH_IN_REPO="$2" + shift 2 + ;; + --path-in-repo=*) + PATH_IN_REPO="${1#*=}" + shift + ;; + --revision) + [[ $# -ge 2 ]] || die "--revision requires a value." + REVISION="$2" + shift 2 + ;; + --revision=*) + REVISION="${1#*=}" + shift + ;; + --commit-message) + [[ $# -ge 2 ]] || die "--commit-message requires a value." + COMMIT_MESSAGE="$2" + shift 2 + ;; + --commit-message=*) + COMMIT_MESSAGE="${1#*=}" + shift + ;; + --private) + PRIVATE=1 + shift + ;; + --skip-create-repo) + CREATE_REPO=0 + shift + ;; + --rclone-remote) + [[ $# -ge 2 ]] || die "--rclone-remote requires a value." + RCLONE_REMOTE="$2" + shift 2 + ;; + --rclone-remote=*) + RCLONE_REMOTE="${1#*=}" + shift + ;; + --drive-dir) + [[ $# -ge 2 ]] || die "--drive-dir requires a value." + DRIVE_DIR="$2" + shift 2 + ;; + --drive-dir=*) + DRIVE_DIR="${1#*=}" + shift + ;; + --remote-dir) + [[ $# -ge 2 ]] || die "--remote-dir requires a value." + REMOTE_DIR="$2" + shift 2 + ;; + --remote-dir=*) + REMOTE_DIR="${1#*=}" + shift + ;; + --remote-path) + [[ $# -ge 2 ]] || die "--remote-path requires a value." + REMOTE_PATH="$2" + shift 2 + ;; + --remote-path=*) + REMOTE_PATH="${1#*=}" + shift + ;; + --rclone-arg) + [[ $# -ge 2 ]] || die "--rclone-arg requires a value." + RCLONE_ARGS+=("$2") + shift 2 + ;; + --rclone-arg=*) + RCLONE_ARGS+=("${1#*=}") + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --*) + die "Unknown argument: $1" + ;; + *) + if [[ -z "$YEAR" ]]; then + YEAR="$1" + shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +[[ -n "$YEAR" ]] || die "YEAR is required." +[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" +is_positive_int "$NUM_SHARDS" || die "--num-shards must be a positive integer." +is_positive_int "$SHARD_READY_RETRIES" || die "SHARD_READY_RETRIES must be a positive integer." +is_positive_int "$SHARD_READY_SLEEP_SECONDS" || die "SHARD_READY_SLEEP_SECONDS must be a positive integer." +[[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "PREDICTION_TARGETS must contain at least one target." + +PUSH_TARGET="${PUSH_TARGET,,}" +case "$PUSH_TARGET" in + drive|hf) + ;; + *) + die "--to/--target must be drive or hf, got: $PUSH_TARGET" + ;; +esac + +case "$NETCDF_DUPLICATE_POLICY" in + error|first|last) + ;; + *) + die "--netcdf-duplicate-policy must be error, first, or last." + ;; +esac + +case "$CREATE_REPO" in + 0|1) + ;; + *) + die "CREATE_REPO must be 0 or 1, got: $CREATE_REPO" + ;; +esac + +case "$PRIVATE" in + 0|1) + ;; + *) + die "PRIVATE must be 0 or 1, got: $PRIVATE" + ;; +esac + +if [[ -z "$INPUT_DIR" ]]; then + INPUT_DIR="$RUN_PATH/eval" +fi + +if [[ -z "$OUTPUT_PATH" ]]; then + OUTPUT_PATH="$INPUT_DIR/era5_predictions_${YEAR}.nc" +fi + +if [[ "${#SHARD_DIRS[@]}" -eq 0 ]]; then + default_shard_dirs "$YEAR" "$INPUT_DIR" +fi +[[ "${#SHARD_DIRS[@]}" -eq 4 ]] || die "Exactly four shard directories are required." + +BUILD_ARGS=( + --year "$YEAR" + --run-path "$RUN_PATH" + --input-dir "$INPUT_DIR" + --output-path "$OUTPUT_PATH" + --shard-dirs "${SHARD_DIRS[@]}" + --num-shards "$NUM_SHARDS" + --prediction-targets "${PREDICTION_TARGET_LIST[@]}" + --netcdf-duplicate-policy "$NETCDF_DUPLICATE_POLICY" +) + +if [[ -n "$CHUNK_ROWS" ]]; then + BUILD_ARGS+=(--chunk-rows "$CHUNK_ROWS") +fi +if [[ -n "$WRITE_TIME_CHUNK" ]]; then + BUILD_ARGS+=(--write-time-chunk "$WRITE_TIME_CHUNK") +fi +if [[ -n "$MAX_MEMORY_GB" ]]; then + BUILD_ARGS+=(--max-memory-gb "$MAX_MEMORY_GB") +fi +if [[ "$OVERWRITE" == "1" ]]; then + BUILD_ARGS+=(--overwrite) +fi + +PUSH_ARGS=( + --to "$PUSH_TARGET" + --nc "$OUTPUT_PATH" +) + +if [[ -n "$DESTINATION_PATH" ]]; then + PUSH_ARGS+=(--path "$DESTINATION_PATH") +fi +if [[ -n "$HF_REPO_ID" ]]; then + PUSH_ARGS+=(--hf-repo-id "$HF_REPO_ID") +fi +if [[ -n "$PATH_IN_REPO" ]]; then + PUSH_ARGS+=(--path-in-repo "$PATH_IN_REPO") +fi +if [[ -n "$REVISION" ]]; then + PUSH_ARGS+=(--revision "$REVISION") +fi +if [[ -n "$COMMIT_MESSAGE" ]]; then + PUSH_ARGS+=(--commit-message "$COMMIT_MESSAGE") +fi +if [[ "$PRIVATE" == "1" ]]; then + PUSH_ARGS+=(--private) +fi +if [[ "$CREATE_REPO" == "0" ]]; then + PUSH_ARGS+=(--skip-create-repo) +fi +if [[ -n "$RCLONE_REMOTE" ]]; then + PUSH_ARGS+=(--rclone-remote "$RCLONE_REMOTE") +fi +if [[ -n "$DRIVE_DIR" ]]; then + PUSH_ARGS+=(--drive-dir "$DRIVE_DIR") +fi +if [[ -n "$REMOTE_DIR" ]]; then + PUSH_ARGS+=(--remote-dir "$REMOTE_DIR") +fi +if [[ -n "$REMOTE_PATH" ]]; then + PUSH_ARGS+=(--remote-path "$REMOTE_PATH") +fi +for rclone_arg in "${RCLONE_ARGS[@]}"; do + PUSH_ARGS+=(--rclone-arg "$rclone_arg") +done + +echo "[$(date)] Starting ERA5 yearly post-processing." +echo "Year: $YEAR" +echo "Run path: $RUN_PATH" +echo "Output path: $OUTPUT_PATH" +echo "Upload target: $PUSH_TARGET" +echo "Prediction targets: ${PREDICTION_TARGET_LIST[*]}" +echo "Expected rank shards per quarter: $NUM_SHARDS" +echo "Shard directories:" +printf ' %s\n' "${SHARD_DIRS[@]}" + +if [[ "$DRY_RUN" == "1" ]]; then + echo "Dry run only; post-processing commands would be:" + printf ' %q' "$SCRIPT_DIR/build_year_nc.sh" "${BUILD_ARGS[@]}" --dry-run + printf '\n' + printf ' %q' "$SCRIPT_DIR/push_year.sh" "${PUSH_ARGS[@]}" --dry-run + printf '\n' + exit 0 +fi + +[[ -f "$SCRIPT_DIR/build_year_nc.sh" ]] || { + echo "Build script not found: $SCRIPT_DIR/build_year_nc.sh" >&2 + exit 1 +} +[[ -f "$SCRIPT_DIR/push_year.sh" ]] || { + echo "Push script not found: $SCRIPT_DIR/push_year.sh" >&2 + exit 1 +} + +wait_for_shards +echo "[$(date)] All expected quarter shard files are present; building yearly NetCDF." +"$SCRIPT_DIR/build_year_nc.sh" "${BUILD_ARGS[@]}" + +[[ -f "$OUTPUT_PATH" ]] || { + echo "Built output not found: $OUTPUT_PATH" >&2 + exit 1 +} + +echo "[$(date)] Uploading yearly NetCDF with push_year.sh." +"$SCRIPT_DIR/push_year.sh" "${PUSH_ARGS[@]}" diff --git a/scripts/era5/push_year.sh b/scripts/era5/push_year.sh new file mode 100755 index 0000000..3622e79 --- /dev/null +++ b/scripts/era5/push_year.sh @@ -0,0 +1,417 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="" +for candidate in "$PWD" "${SLURM_SUBMIT_DIR:-}" "${SLURM_SUBMIT_DIR:+$SLURM_SUBMIT_DIR/../..}" "$SCRIPT_DIR/../.."; do + [[ -n "$candidate" ]] || continue + candidate="$(cd "$candidate" 2>/dev/null && pwd)" || continue + if [[ -f "$candidate/setup.py" && -d "$candidate/scripts/era5" ]]; then + REPO_ROOT="$candidate" + break + fi +done +[[ -n "$REPO_ROOT" ]] || { + echo "Could not locate EcoPerceiver repo root." >&2 + exit 2 +} +cd "$REPO_ROOT" + +DEFAULT_HF_REPO_ID="ludolara/era5" +DEFAULT_RCLONE_REMOTE="gdrive:" +DEFAULT_DRIVE_DIR="ep_era5" +PYTHON_BIN="${PYTHON_BIN:-}" + +if [[ -f "$REPO_ROOT/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$REPO_ROOT/.env" + set +a +fi + +usage() { + cat >&2 <&2 + usage + exit 2 +} + +join_remote_dir() { + local remote="$1" + local drive_dir="$2" + + remote="${remote%/}" + drive_dir="${drive_dir#/}" + drive_dir="${drive_dir%/}" + + if [[ -z "$drive_dir" ]]; then + echo "$remote" + elif [[ "$remote" == *: ]]; then + echo "${remote}${drive_dir}" + else + echo "${remote}/${drive_dir}" + fi +} + +join_remote_file() { + local remote_dir="$1" + local filename="$2" + + if [[ "$remote_dir" == *: ]]; then + echo "${remote_dir}${filename}" + else + echo "${remote_dir%/}/${filename}" + fi +} + +remote_parent_dir() { + local remote_path="$1" + + if [[ "$remote_path" == */* ]]; then + echo "${remote_path%/*}" + elif [[ "$remote_path" == *:* ]]; then + echo "${remote_path%%:*}:" + else + echo "." + fi +} + +print_rclone_command() { + printf 'rclone copyto %q %q --progress' "$NC_PATH" "$REMOTE_PATH" + if [[ "${#RCLONE_ARGS[@]}" -gt 0 ]]; then + printf ' %q' "${RCLONE_ARGS[@]}" + fi + printf '\n' +} + +TARGET="${TARGET:-}" +NC_PATH="${NC_PATH:-}" +DESTINATION_PATH="" +DRY_RUN=0 +HF_REPO_ID="${HF_REPO_ID:-$DEFAULT_HF_REPO_ID}" +PATH_IN_REPO="${PATH_IN_REPO:-}" +REVISION="${HF_REVISION:-main}" +COMMIT_MESSAGE="${HF_COMMIT_MESSAGE:-}" +CREATE_REPO=1 +PRIVATE=0 +RCLONE_REMOTE="${RCLONE_REMOTE:-$DEFAULT_RCLONE_REMOTE}" +DRIVE_DIR="${DRIVE_DIR:-$DEFAULT_DRIVE_DIR}" +REMOTE_DIR="${REMOTE_DIR:-}" +REMOTE_PATH="${REMOTE_PATH:-}" +RCLONE_ARGS=() + +if [[ -z "$PYTHON_BIN" ]]; then + if [[ -n "${ECOPERCEIVER_ENV:-}" && -x "$ECOPERCEIVER_ENV/bin/python" ]]; then + PYTHON_BIN="$ECOPERCEIVER_ENV/bin/python" + elif [[ -n "${SCRATCH:-}" && -x "$SCRATCH/env/ecoperceiver/bin/python" ]]; then + PYTHON_BIN="$SCRATCH/env/ecoperceiver/bin/python" + else + PYTHON_BIN="python3" + fi +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --to|--target) + [[ $# -ge 2 ]] || die "$1 requires a value." + TARGET="$2" + shift 2 + ;; + --to=*|--target=*) + TARGET="${1#*=}" + shift + ;; + --nc|--file) + [[ $# -ge 2 ]] || die "$1 requires a value." + NC_PATH="$2" + shift 2 + ;; + --nc=*|--file=*) + NC_PATH="${1#*=}" + shift + ;; + --path) + [[ $# -ge 2 ]] || die "--path requires a value." + DESTINATION_PATH="$2" + shift 2 + ;; + --path=*) + DESTINATION_PATH="${1#*=}" + shift + ;; + --hf-repo-id) + [[ $# -ge 2 ]] || die "--hf-repo-id requires a value." + HF_REPO_ID="$2" + shift 2 + ;; + --hf-repo-id=*) + HF_REPO_ID="${1#*=}" + shift + ;; + --path-in-repo) + [[ $# -ge 2 ]] || die "--path-in-repo requires a value." + PATH_IN_REPO="$2" + shift 2 + ;; + --path-in-repo=*) + PATH_IN_REPO="${1#*=}" + shift + ;; + --revision) + [[ $# -ge 2 ]] || die "--revision requires a value." + REVISION="$2" + shift 2 + ;; + --revision=*) + REVISION="${1#*=}" + shift + ;; + --commit-message) + [[ $# -ge 2 ]] || die "--commit-message requires a value." + COMMIT_MESSAGE="$2" + shift 2 + ;; + --commit-message=*) + COMMIT_MESSAGE="${1#*=}" + shift + ;; + --private) + PRIVATE=1 + shift + ;; + --skip-create-repo) + CREATE_REPO=0 + shift + ;; + --rclone-remote) + [[ $# -ge 2 ]] || die "--rclone-remote requires a value." + RCLONE_REMOTE="$2" + shift 2 + ;; + --rclone-remote=*) + RCLONE_REMOTE="${1#*=}" + shift + ;; + --drive-dir) + [[ $# -ge 2 ]] || die "--drive-dir requires a value." + DRIVE_DIR="$2" + shift 2 + ;; + --drive-dir=*) + DRIVE_DIR="${1#*=}" + shift + ;; + --remote-dir) + [[ $# -ge 2 ]] || die "--remote-dir requires a value." + REMOTE_DIR="$2" + shift 2 + ;; + --remote-dir=*) + REMOTE_DIR="${1#*=}" + shift + ;; + --remote-path) + [[ $# -ge 2 ]] || die "--remote-path requires a value." + REMOTE_PATH="$2" + shift 2 + ;; + --remote-path=*) + REMOTE_PATH="${1#*=}" + shift + ;; + --rclone-arg) + [[ $# -ge 2 ]] || die "--rclone-arg requires a value." + RCLONE_ARGS+=("$2") + shift 2 + ;; + --rclone-arg=*) + RCLONE_ARGS+=("${1#*=}") + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --*) + die "Unknown argument: $1" + ;; + *) + if [[ -z "$TARGET" && ( "$1" == "hf" || "$1" == "drive" ) ]]; then + TARGET="$1" + elif [[ -z "$NC_PATH" ]]; then + NC_PATH="$1" + else + die "Unknown argument: $1" + fi + shift + ;; + esac +done + +TARGET="${TARGET,,}" +[[ -n "$TARGET" ]] || die "--to hf|drive is required." +case "$TARGET" in + hf|drive) + ;; + *) + die "--to must be hf or drive, got: $TARGET" + ;; +esac + +if [[ -n "$DESTINATION_PATH" ]]; then + if [[ "$TARGET" == "drive" ]]; then + [[ -z "$REMOTE_PATH" ]] || die "Use either --path or --remote-path, not both." + REMOTE_PATH="$DESTINATION_PATH" + else + [[ -z "$PATH_IN_REPO" ]] || die "Use either --path or --path-in-repo, not both." + PATH_IN_REPO="$DESTINATION_PATH" + fi +fi + +[[ -n "$NC_PATH" ]] || die "An existing .nc file path is required." +NC_PATH="${NC_PATH/#\~/$HOME}" +[[ -f "$NC_PATH" ]] || die "NetCDF file not found: $NC_PATH" +[[ "$NC_PATH" == *.nc ]] || die "NetCDF file must end in .nc: $NC_PATH" + +if [[ "$TARGET" == "hf" ]]; then + [[ -n "$HF_REPO_ID" ]] || die "HF repo id is required." + if [[ -z "$PATH_IN_REPO" ]]; then + PATH_IN_REPO="$(basename "$NC_PATH")" + fi + PATH_IN_REPO="${PATH_IN_REPO#/}" + if [[ -z "$COMMIT_MESSAGE" ]]; then + COMMIT_MESSAGE="Upload $(basename "$NC_PATH")" + fi + + echo "[$(date)] Hugging Face upload target:" + echo "Source: $NC_PATH" + echo "Destination: dataset/$HF_REPO_ID@$REVISION:$PATH_IN_REPO" + + if [[ "$DRY_RUN" == "1" ]]; then + echo "Dry run only; no file uploaded." + exit 0 + fi + + if [[ -z "${HF_WRITE:-}" ]]; then + echo "HF_WRITE is required in the environment or .env." >&2 + exit 1 + fi + + "$PYTHON_BIN" - "$HF_REPO_ID" "$NC_PATH" "$PATH_IN_REPO" "$REVISION" "$COMMIT_MESSAGE" "$CREATE_REPO" "$PRIVATE" <<'PY' +import os +from pathlib import Path +import sys + +repo_id, nc_path, path_in_repo, revision, commit_message, create_repo, private = sys.argv[1:8] +token = os.environ.get("HF_WRITE") + +try: + from huggingface_hub import HfApi +except ModuleNotFoundError as exc: + missing = exc.name or "a dependency" + raise SystemExit( + "Missing Python dependency while importing huggingface_hub: " + f"{missing}. Install with: python3 -m pip install --user huggingface_hub filelock" + ) from exc + +api = HfApi() +if create_repo == "1": + api.create_repo( + repo_id=repo_id, + repo_type="dataset", + token=token, + private=(private == "1"), + exist_ok=True, + ) + +result = api.upload_file( + path_or_fileobj=str(Path(nc_path)), + path_in_repo=path_in_repo, + repo_id=repo_id, + repo_type="dataset", + revision=revision, + token=token, + commit_message=commit_message, +) +print(f"Upload complete: {result}") +PY +else + command -v rclone >/dev/null 2>&1 || { + echo "rclone is required for Drive uploads." >&2 + exit 1 + } + + if [[ -z "$REMOTE_PATH" ]]; then + if [[ -z "$REMOTE_DIR" ]]; then + REMOTE_DIR="$(join_remote_dir "$RCLONE_REMOTE" "$DRIVE_DIR")" + fi + REMOTE_PATH="$(join_remote_file "$REMOTE_DIR" "$(basename "$NC_PATH")")" + elif [[ -z "$REMOTE_DIR" ]]; then + REMOTE_DIR="$(remote_parent_dir "$REMOTE_PATH")" + fi + + echo "[$(date)] Drive/rclone upload target:" + echo "Source: $NC_PATH" + echo "Destination: $REMOTE_PATH" + + if [[ "$DRY_RUN" == "1" ]]; then + echo "Dry run only; upload command would be:" + print_rclone_command + exit 0 + fi + + rclone mkdir "$REMOTE_DIR" + rclone copyto "$NC_PATH" "$REMOTE_PATH" --progress "${RCLONE_ARGS[@]}" + echo "[$(date)] Upload complete: $REMOTE_PATH" +fi diff --git a/scripts/era5/push_year_to_drive.sh b/scripts/era5/push_year_to_drive.sh deleted file mode 100755 index fec74ee..0000000 --- a/scripts/era5/push_year_to_drive.sh +++ /dev/null @@ -1,266 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -cd "$REPO_ROOT" - -DEFAULT_RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" - -usage() { - cat >&2 <&2 - usage - exit 2 -} - -join_remote_dir() { - local remote="$1" - local drive_dir="$2" - - remote="${remote%/}" - drive_dir="${drive_dir#/}" - drive_dir="${drive_dir%/}" - - if [[ -z "$drive_dir" ]]; then - echo "$remote" - elif [[ "$remote" == *: ]]; then - echo "${remote}${drive_dir}" - else - echo "${remote}/${drive_dir}" - fi -} - -join_remote_file() { - local remote_dir="$1" - local filename="$2" - - if [[ "$remote_dir" == *: ]]; then - echo "${remote_dir}${filename}" - else - echo "${remote_dir%/}/${filename}" - fi -} - -YEAR="${YEAR:-}" -RUN_PATH="${RUN_PATH:-$DEFAULT_RUN_PATH}" -INPUT_DIR="${INPUT_DIR:-}" -OUTPUT_PATH="${OUTPUT_PATH:-}" -ENGINE="${XARRAY_ENGINE:-h5netcdf}" -ALLOW_MISSING=0 -OVERWRITE=0 -DRY_RUN=0 -RCLONE_REMOTE="${RCLONE_REMOTE:-gdrive:}" -DRIVE_DIR="${DRIVE_DIR:-ep_era5}" -REMOTE_DIR="${REMOTE_DIR:-}" -INPUTS=() -RCLONE_ARGS=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --year) - [[ $# -ge 2 ]] || die "--year requires a value." - YEAR="$2" - shift 2 - ;; - --year=*) - YEAR="${1#*=}" - shift - ;; - --run-path) - [[ $# -ge 2 ]] || die "--run-path requires a value." - RUN_PATH="$2" - shift 2 - ;; - --run-path=*) - RUN_PATH="${1#*=}" - shift - ;; - --input-dir) - [[ $# -ge 2 ]] || die "--input-dir requires a value." - INPUT_DIR="$2" - shift 2 - ;; - --input-dir=*) - INPUT_DIR="${1#*=}" - shift - ;; - --output-path) - [[ $# -ge 2 ]] || die "--output-path requires a value." - OUTPUT_PATH="$2" - shift 2 - ;; - --output-path=*) - OUTPUT_PATH="${1#*=}" - shift - ;; - --engine) - [[ $# -ge 2 ]] || die "--engine requires a value." - ENGINE="$2" - shift 2 - ;; - --engine=*) - ENGINE="${1#*=}" - shift - ;; - --inputs) - [[ $# -ge 5 ]] || die "--inputs requires four paths." - INPUTS=("$2" "$3" "$4" "$5") - shift 5 - ;; - --allow-missing) - ALLOW_MISSING=1 - shift - ;; - --overwrite) - OVERWRITE=1 - shift - ;; - --rclone-remote) - [[ $# -ge 2 ]] || die "--rclone-remote requires a value." - RCLONE_REMOTE="$2" - shift 2 - ;; - --rclone-remote=*) - RCLONE_REMOTE="${1#*=}" - shift - ;; - --drive-dir) - [[ $# -ge 2 ]] || die "--drive-dir requires a value." - DRIVE_DIR="$2" - shift 2 - ;; - --drive-dir=*) - DRIVE_DIR="${1#*=}" - shift - ;; - --remote-dir) - [[ $# -ge 2 ]] || die "--remote-dir requires a value." - REMOTE_DIR="$2" - shift 2 - ;; - --remote-dir=*) - REMOTE_DIR="${1#*=}" - shift - ;; - --rclone-arg) - [[ $# -ge 2 ]] || die "--rclone-arg requires a value." - RCLONE_ARGS+=("$2") - shift 2 - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - --*) - die "Unknown argument: $1" - ;; - *) - if [[ -z "$YEAR" ]]; then - YEAR="$1" - shift - else - die "Unknown argument: $1" - fi - ;; - esac -done - -[[ -n "$YEAR" ]] || die "YEAR is required." -[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" - -if [[ -z "$INPUT_DIR" ]]; then - INPUT_DIR="$RUN_PATH/eval" -fi - -if [[ -z "$OUTPUT_PATH" ]]; then - OUTPUT_PATH="$INPUT_DIR/era5_predictions_${YEAR}.nc" -fi - -if [[ -z "$REMOTE_DIR" ]]; then - REMOTE_DIR="$(join_remote_dir "$RCLONE_REMOTE" "$DRIVE_DIR")" -fi - -REMOTE_PATH="$(join_remote_file "$REMOTE_DIR" "$(basename "$OUTPUT_PATH")")" - -ASSEMBLE_ARGS=( - --year "$YEAR" - --run-path "$RUN_PATH" - --input-dir "$INPUT_DIR" - --output-path "$OUTPUT_PATH" - --engine "$ENGINE" -) - -if [[ "${#INPUTS[@]}" -gt 0 ]]; then - ASSEMBLE_ARGS+=(--inputs "${INPUTS[@]}") -fi -if [[ "$ALLOW_MISSING" == "1" ]]; then - ASSEMBLE_ARGS+=(--allow-missing) -fi -if [[ "$OVERWRITE" == "1" ]]; then - ASSEMBLE_ARGS+=(--overwrite) -fi -if [[ "$DRY_RUN" == "1" ]]; then - ASSEMBLE_ARGS+=(--dry-run) -fi - -echo "[$(date)] Assembling yearly ERA5 NetCDF." -echo "Year: $YEAR" -echo "Output path: $OUTPUT_PATH" -"$SCRIPT_DIR/build_year_nc.sh" "${ASSEMBLE_ARGS[@]}" - -if [[ "$DRY_RUN" == "1" ]]; then - echo "[$(date)] Dry-run upload target: $REMOTE_PATH" - printf 'Dry-run upload command: rclone copyto %q %q --progress' "$OUTPUT_PATH" "$REMOTE_PATH" - if [[ "${#RCLONE_ARGS[@]}" -gt 0 ]]; then - printf ' %q' "${RCLONE_ARGS[@]}" - fi - printf '\n' - exit 0 -fi - -if [[ ! -f "$OUTPUT_PATH" ]]; then - echo "Assembled output not found: $OUTPUT_PATH" >&2 - exit 1 -fi - -echo "[$(date)] Uploading yearly ERA5 NetCDF with rclone." -echo "Destination: $REMOTE_PATH" -rclone mkdir "$REMOTE_DIR" -rclone copyto "$OUTPUT_PATH" "$REMOTE_PATH" --progress "${RCLONE_ARGS[@]}" -rclone lsf "$REMOTE_PATH" -echo "[$(date)] Upload complete: $REMOTE_PATH" diff --git a/scripts/era5/push_year_to_hf.sh b/scripts/era5/push_year_to_hf.sh deleted file mode 100755 index 5e13102..0000000 --- a/scripts/era5/push_year_to_hf.sh +++ /dev/null @@ -1,317 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -cd "$REPO_ROOT" - -DEFAULT_RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" -DEFAULT_HF_REPO_ID="ludolara/era5" -PYTHON_BIN="${PYTHON_BIN:-}" - -if [[ -f "$REPO_ROOT/.env" ]]; then - set -a - # shellcheck disable=SC1091 - source "$REPO_ROOT/.env" - set +a -fi - -usage() { - cat >&2 <&2 - usage - exit 2 -} - -YEAR="${YEAR:-}" -RUN_PATH="${RUN_PATH:-$DEFAULT_RUN_PATH}" -INPUT_DIR="${INPUT_DIR:-}" -OUTPUT_PATH="${OUTPUT_PATH:-}" -ENGINE="${XARRAY_ENGINE:-h5netcdf}" -ALLOW_MISSING=0 -OVERWRITE=0 -DRY_RUN=0 -HF_REPO_ID="${HF_REPO_ID:-$DEFAULT_HF_REPO_ID}" -PATH_IN_REPO="${PATH_IN_REPO:-}" -REVISION="${HF_REVISION:-main}" -COMMIT_MESSAGE="${HF_COMMIT_MESSAGE:-}" -CREATE_REPO=1 -PRIVATE=0 -INPUTS=() - -if [[ -z "$PYTHON_BIN" ]]; then - if [[ -n "${ECOPERCEIVER_ENV:-}" && -x "$ECOPERCEIVER_ENV/bin/python" ]]; then - PYTHON_BIN="$ECOPERCEIVER_ENV/bin/python" - elif [[ -n "${SCRATCH:-}" && -x "$SCRATCH/env/ecoperceiver/bin/python" ]]; then - PYTHON_BIN="$SCRATCH/env/ecoperceiver/bin/python" - else - PYTHON_BIN="python3" - fi -fi - -while [[ $# -gt 0 ]]; do - case "$1" in - --year) - [[ $# -ge 2 ]] || die "--year requires a value." - YEAR="$2" - shift 2 - ;; - --year=*) - YEAR="${1#*=}" - shift - ;; - --run-path) - [[ $# -ge 2 ]] || die "--run-path requires a value." - RUN_PATH="$2" - shift 2 - ;; - --run-path=*) - RUN_PATH="${1#*=}" - shift - ;; - --input-dir) - [[ $# -ge 2 ]] || die "--input-dir requires a value." - INPUT_DIR="$2" - shift 2 - ;; - --input-dir=*) - INPUT_DIR="${1#*=}" - shift - ;; - --output-path) - [[ $# -ge 2 ]] || die "--output-path requires a value." - OUTPUT_PATH="$2" - shift 2 - ;; - --output-path=*) - OUTPUT_PATH="${1#*=}" - shift - ;; - --engine) - [[ $# -ge 2 ]] || die "--engine requires a value." - ENGINE="$2" - shift 2 - ;; - --engine=*) - ENGINE="${1#*=}" - shift - ;; - --inputs) - [[ $# -ge 5 ]] || die "--inputs requires four paths." - INPUTS=("$2" "$3" "$4" "$5") - shift 5 - ;; - --allow-missing) - ALLOW_MISSING=1 - shift - ;; - --overwrite) - OVERWRITE=1 - shift - ;; - --hf-repo-id) - [[ $# -ge 2 ]] || die "--hf-repo-id requires a value." - HF_REPO_ID="$2" - shift 2 - ;; - --hf-repo-id=*) - HF_REPO_ID="${1#*=}" - shift - ;; - --path-in-repo) - [[ $# -ge 2 ]] || die "--path-in-repo requires a value." - PATH_IN_REPO="$2" - shift 2 - ;; - --path-in-repo=*) - PATH_IN_REPO="${1#*=}" - shift - ;; - --revision) - [[ $# -ge 2 ]] || die "--revision requires a value." - REVISION="$2" - shift 2 - ;; - --revision=*) - REVISION="${1#*=}" - shift - ;; - --commit-message) - [[ $# -ge 2 ]] || die "--commit-message requires a value." - COMMIT_MESSAGE="$2" - shift 2 - ;; - --commit-message=*) - COMMIT_MESSAGE="${1#*=}" - shift - ;; - --private) - PRIVATE=1 - shift - ;; - --skip-create-repo) - CREATE_REPO=0 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - --*) - die "Unknown argument: $1" - ;; - *) - if [[ -z "$YEAR" ]]; then - YEAR="$1" - shift - else - die "Unknown argument: $1" - fi - ;; - esac -done - -[[ -n "$YEAR" ]] || die "YEAR is required." -[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" -[[ -n "$HF_REPO_ID" ]] || die "HF repo id is required." - -if [[ -z "$INPUT_DIR" ]]; then - INPUT_DIR="$RUN_PATH/eval" -fi - -if [[ -z "$OUTPUT_PATH" ]]; then - OUTPUT_PATH="$INPUT_DIR/era5_predictions_${YEAR}.nc" -fi - -if [[ -z "$PATH_IN_REPO" ]]; then - PATH_IN_REPO="$(basename "$OUTPUT_PATH")" -fi -PATH_IN_REPO="${PATH_IN_REPO#/}" - -if [[ -z "$COMMIT_MESSAGE" ]]; then - COMMIT_MESSAGE="Upload ERA5 predictions for ${YEAR}" -fi - -ASSEMBLE_ARGS=( - --year "$YEAR" - --run-path "$RUN_PATH" - --input-dir "$INPUT_DIR" - --output-path "$OUTPUT_PATH" - --engine "$ENGINE" -) - -if [[ "${#INPUTS[@]}" -gt 0 ]]; then - ASSEMBLE_ARGS+=(--inputs "${INPUTS[@]}") -fi -if [[ "$ALLOW_MISSING" == "1" ]]; then - ASSEMBLE_ARGS+=(--allow-missing) -fi -if [[ "$OVERWRITE" == "1" ]]; then - ASSEMBLE_ARGS+=(--overwrite) -fi -if [[ "$DRY_RUN" == "1" ]]; then - ASSEMBLE_ARGS+=(--dry-run) -fi - -echo "[$(date)] Assembling yearly ERA5 NetCDF." -echo "Year: $YEAR" -echo "Output path: $OUTPUT_PATH" -"$SCRIPT_DIR/build_year_nc.sh" "${ASSEMBLE_ARGS[@]}" - -if [[ "$DRY_RUN" == "1" ]]; then - echo "[$(date)] Dry-run upload target: dataset/$HF_REPO_ID@$REVISION:$PATH_IN_REPO" - exit 0 -fi - -if [[ ! -f "$OUTPUT_PATH" ]]; then - echo "Assembled output not found: $OUTPUT_PATH" >&2 - exit 1 -fi - -if [[ -z "${HF_WRITE:-}" ]]; then - echo "HF_WRITE is required in the environment or .env." >&2 - exit 1 -fi - -echo "[$(date)] Uploading yearly ERA5 NetCDF to Hugging Face dataset." -echo "Destination: dataset/$HF_REPO_ID@$REVISION:$PATH_IN_REPO" - -"$PYTHON_BIN" - "$HF_REPO_ID" "$OUTPUT_PATH" "$PATH_IN_REPO" "$REVISION" "$COMMIT_MESSAGE" "$CREATE_REPO" "$PRIVATE" <<'PY' -import os -from pathlib import Path -import sys - -repo_id, output_path, path_in_repo, revision, commit_message, create_repo, private = sys.argv[1:8] -token = os.environ.get("HF_WRITE") - -try: - from huggingface_hub import HfApi -except ModuleNotFoundError as exc: - missing = exc.name or "a dependency" - raise SystemExit( - "Missing Python dependency while importing huggingface_hub: " - f"{missing}. Install with: python3 -m pip install --user huggingface_hub filelock" - ) from exc - -api = HfApi() -if create_repo == "1": - api.create_repo( - repo_id=repo_id, - repo_type="dataset", - token=token, - private=(private == "1"), - exist_ok=True, - ) - -result = api.upload_file( - path_or_fileobj=str(Path(output_path)), - path_in_repo=path_in_repo, - repo_id=repo_id, - repo_type="dataset", - revision=revision, - token=token, - commit_message=commit_message, -) -print(f"Upload complete: {result}") -PY diff --git a/scripts/era5/run.sh b/scripts/era5/run.sh deleted file mode 100755 index cca29c4..0000000 --- a/scripts/era5/run.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -#SBATCH --nodes=1 -#SBATCH --gres=gpu:h100:4 -#SBATCH --mem=64G -#SBATCH --cpus-per-task=32 -#SBATCH --time=1:00:00 -#SBATCH --output=/scratch/l/luislara/EcoPerceiver/logs/run.out -#SBATCH --error=/scratch/l/luislara/EcoPerceiver/logs/run.error -#SBATCH --open-mode=truncate -#SBATCH --job-name=run -#SBATCH --account=aip-pal - -set -euo pipefail - -source $SCRATCH/env/ecoperceiver/bin/activate -cd ~/links/scratch/EcoPerceiver - -export PYTHONUNBUFFERED=1 -echo "[$(date)] Starting single-process inference job ${SLURM_JOB_ID:-local} on ${SLURM_JOB_NODELIST:-local}" -echo "stdout: /scratch/l/luislara/EcoPerceiver/logs/run.out" -echo "stderr: /scratch/l/luislara/EcoPerceiver/logs/run.error" - -RUN_PATH="experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0" -DB_PATH="/home/l/luislara/links/projects/aip-pal/luislara/ep/data/era5.db" -INITIAL_DATE="2017-06-01" -FINAL_DATE="2017-06-30" -DATE_TAG="${INITIAL_DATE//-/}_to_${FINAL_DATE//-/}" -OUTPUT_CSV="$RUN_PATH/eval/era5_predictions_${DATE_TAG}_single.csv" -IGBP_EXCLUDED=(WAT SNO BSV URB CRO CVM) -PREDICTION_TARGETS=(pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE) - -echo "Output CSV: $OUTPUT_CSV" - -python3 -u eval/era5/test_era5.py \ - --run-path "$RUN_PATH" \ - --checkpoint-path checkpoint-11.pth \ - --db-path "$DB_PATH" \ - --initial-date "$INITIAL_DATE" \ - --final-date "$FINAL_DATE" \ - --output-csv "$OUTPUT_CSV" \ - --batch-size 32768 \ - --num-workers 16 \ - --prefetch-factor 1 \ - --exclude-igbp "${IGBP_EXCLUDED[@]}" \ - --prediction-targets "${PREDICTION_TARGETS[@]}" \ - --gpp-solar-threshold 2.0 \ - --max-samples 1000000 diff --git a/scripts/era5/run_multi_gpu.sh b/scripts/era5/run_multi_gpu.sh index 060bb5e..3e5cd97 100755 --- a/scripts/era5/run_multi_gpu.sh +++ b/scripts/era5/run_multi_gpu.sh @@ -12,8 +12,23 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="" +for candidate in "$PWD" "${SLURM_SUBMIT_DIR:-}" "${SLURM_SUBMIT_DIR:+$SLURM_SUBMIT_DIR/../..}" "$SCRIPT_DIR/../.."; do + [[ -n "$candidate" ]] || continue + candidate="$(cd "$candidate" 2>/dev/null && pwd)" || continue + if [[ -f "$candidate/setup.py" && -d "$candidate/scripts/era5" ]]; then + REPO_ROOT="$candidate" + break + fi +done +[[ -n "$REPO_ROOT" ]] || { + echo "Could not locate EcoPerceiver repo root." >&2 + exit 2 +} + source "$SCRATCH/env/ecoperceiver/bin/activate" -cd ~/links/scratch/EcoPerceiver +cd "$REPO_ROOT" export PYTHONUNBUFFERED=1 export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" @@ -28,16 +43,6 @@ append_prediction_targets() { done } -append_merge_prediction_targets() { - local raw value - raw="${1//,/ }" - for value in $raw; do - if [[ -n "$value" ]]; then - MERGE_PREDICTION_TARGET_LIST+=("$value") - fi - done -} - GPU_LOG_OUT="${GPU_LOG_OUT:-/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.out}" GPU_LOG_ERR="${GPU_LOG_ERR:-/scratch/l/luislara/EcoPerceiver/logs/run_multi_gpu.error}" @@ -79,9 +84,7 @@ if [[ -z "$DB_PATH" ]]; then DB_PATH="${DATA_ROOT}/${DB_LABEL}/era5_${DB_LABEL}.db" fi DATE_TAG="${INITIAL_DATE//-/}_to_${FINAL_DATE//-/}" -OUTPUT_CSV="${OUTPUT_CSV:-$RUN_PATH/eval/era5_predictions_${DATE_TAG}.csv}" SHARD_DIR="${SHARD_DIR:-$RUN_PATH/eval/.era5_predictions_${DATE_TAG}_multi_gpu_shards}" -LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" IGBP_EXCLUDED=(WAT SNO BSV URB CRO CVM) PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" PREDICTION_TARGET_LIST=(pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE) @@ -91,11 +94,6 @@ if [[ -n "$PREDICTION_TARGETS_ENV" ]]; then fi PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" -MERGE_PREDICTION_TARGETS_ENV="${MERGE_PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" -MERGE_PREDICTION_TARGET_LIST=() -append_merge_prediction_targets "$MERGE_PREDICTION_TARGETS_ENV" -MERGE_PREDICTION_TARGETS_VALUE="${MERGE_PREDICTION_TARGET_LIST[*]}" -export MERGE_PREDICTION_TARGETS="$MERGE_PREDICTION_TARGETS_VALUE" BATCH_SIZE_PER_GPU="${BATCH_SIZE_PER_GPU:-32768}" NUM_WORKERS_PER_GPU="${NUM_WORKERS_PER_GPU:-12}" @@ -126,7 +124,6 @@ fi echo "Date window: $INITIAL_DATE to $FINAL_DATE" echo "DB path: $DB_PATH" echo "Checkpoint path: $CHECKPOINT_PATH" -echo "Output CSV: $OUTPUT_CSV" echo "Shard dir: $SHARD_DIR" echo "GPUs: 4" echo "Batch size per GPU: $BATCH_SIZE_PER_GPU" @@ -134,8 +131,7 @@ echo "Dataloader workers per GPU: $NUM_WORKERS_PER_GPU" echo "Prefetch factor: $PREFETCH_FACTOR" echo "Dataloader in-order delivery: $DATALOADER_IN_ORDER_LABEL" echo "Prediction targets: $PREDICTION_TARGETS_VALUE" -echo "Merge prediction targets: $MERGE_PREDICTION_TARGETS_VALUE" -echo "Temporary shard order key: __sample_order (dropped during post-processing)" +echo "Temporary shard order key: __sample_order" echo "Distributed timeout minutes: $DIST_TIMEOUT_MINUTES" torchrun --standalone --nnodes=1 --nproc-per-node=4 \ @@ -145,7 +141,6 @@ torchrun --standalone --nnodes=1 --nproc-per-node=4 \ --db-path "$DB_PATH" \ --initial-date "$INITIAL_DATE" \ --final-date "$FINAL_DATE" \ - --output-csv "$OUTPUT_CSV" \ --shard-dir "$SHARD_DIR" \ --batch-size "$BATCH_SIZE_PER_GPU" \ --num-workers "$NUM_WORKERS_PER_GPU" \ @@ -154,32 +149,7 @@ torchrun --standalone --nnodes=1 --nproc-per-node=4 \ "${DATALOADER_ORDER_ARGS[@]}" \ --exclude-igbp "${IGBP_EXCLUDED[@]}" \ --prediction-targets "${PREDICTION_TARGET_LIST[@]}" \ - --gpp-solar-threshold 2.0 \ - --skip-merge + --gpp-solar-threshold 2.0 # --max-samples 1000000 \ -echo "[$(date)] GPU inference shards complete. Submitting scripts/era5/merge_prediction_shards.sh on CPU." -POST_FORMAT="${POST_FORMAT:-csv}" -case "$POST_FORMAT" in - csv) - POST_OUTPUT_PATH="${OUTPUT_PATH:-$OUTPUT_CSV}" - ;; - netcdf) - POST_OUTPUT_PATH="${OUTPUT_PATH:-$RUN_PATH/eval/era5_predictions_${DATE_TAG}.nc}" - ;; - *) - echo "POST_FORMAT must be csv or netcdf, got: $POST_FORMAT" >&2 - exit 2 - ;; -esac - -mkdir -p "$LOG_DIR" -POST_JOB_ID="$( - PREDICTION_TARGETS="$MERGE_PREDICTION_TARGETS_VALUE" sbatch --parsable \ - --job-name "merge-shards-${DATE_TAG}" \ - --output "$LOG_DIR/merge_prediction_shards_${DATE_TAG}.out" \ - --error "$LOG_DIR/merge_prediction_shards_${DATE_TAG}.error" \ - --export=ALL,RUN_PATH="$RUN_PATH",INITIAL_DATE="$INITIAL_DATE",FINAL_DATE="$FINAL_DATE",POST_FORMAT="$POST_FORMAT",SHARD_DIR="$SHARD_DIR",OUTPUT_PATH="$POST_OUTPUT_PATH" \ - scripts/era5/merge_prediction_shards.sh -)" -echo "[$(date)] Submitted ERA5 CPU post-processing job: $POST_JOB_ID (format: $POST_FORMAT, output: $POST_OUTPUT_PATH)" +echo "[$(date)] GPU inference shards complete." diff --git a/scripts/era5/run_year.sh b/scripts/era5/run_year.sh new file mode 100755 index 0000000..2e84bf9 --- /dev/null +++ b/scripts/era5/run_year.sh @@ -0,0 +1,744 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="" +for candidate in "$PWD" "${SLURM_SUBMIT_DIR:-}" "${SLURM_SUBMIT_DIR:+$SLURM_SUBMIT_DIR/../..}" "$SCRIPT_DIR/../.."; do + [[ -n "$candidate" ]] || continue + candidate="$(cd "$candidate" 2>/dev/null && pwd)" || continue + if [[ -f "$candidate/setup.py" && -d "$candidate/scripts/era5" ]]; then + REPO_ROOT="$candidate" + break + fi +done +[[ -n "$REPO_ROOT" ]] || { + echo "Could not locate EcoPerceiver repo root." >&2 + exit 2 +} +cd "$REPO_ROOT" + +usage() { + cat >&2 < /2016_2017/era5_2016_2017.db + 2017 -> /2016_2017/era5_2016_2017.db + +Options: + --year YEAR Year to infer. Positional YEAR is also accepted. + --db-path PATH Explicit ERA5 SQLite database path. + --db-start-year YEAR First year in the two-year database. + --db-label LABEL Database label, for example 2016_2017. + --data-root PATH Root directory containing date-range DB folders. + --run-path PATH EcoPerceiver run directory. + --checkpoint-path PATH Checkpoint path relative to run-path, or absolute. + --prediction-targets LIST Inference prediction targets, comma or space separated. + --post-process-prediction-targets LIST + Yearly NetCDF prediction targets. Default: POST_PROCESS_PREDICTION_TARGETS or pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE. + --num-shards N Expected rank CSV shards per quarter for post-processing. Default: NUM_SHARDS or 4. + --parallel Submit all quarters immediately. Default. + --sequential Chain quarter jobs with afterok dependencies. + --post-process Submit yearly NetCDF build/upload post-processing. Default. + --skip-post-processing Only submit inference shard jobs. + --post-output-path PATH Yearly NetCDF output path for post-processing. + --overwrite-year-nc Replace an existing yearly NetCDF during post-processing. + --to TARGET Upload target for post-processing: drive or hf. Default: drive. + --target TARGET Alias for --to. + --push-target TARGET Alias for --to. + --path PATH Destination path passed to push_year.sh. + --rclone-remote REMOTE rclone remote for Drive uploads. + --drive-dir PATH Google Drive folder/path for Drive uploads. + --remote-dir REMOTE_DIR Full rclone destination directory. + --remote-path REMOTE_PATH Full rclone destination file path. + --rclone-arg ARG Extra argument passed to rclone copyto. Repeatable. + --hf-repo-id REPO_ID Dataset repo id when using --to hf. + --path-in-repo PATH Destination path in the dataset when using --to hf. + --revision REVISION Branch/revision to upload to when using --to hf. + --commit-message MESSAGE Commit message for the upload when using --to hf. + --private Create the dataset as private when using --to hf. + --skip-create-repo Do not create the dataset when using --to hf. + --netcdf-duplicate-policy P Duplicate policy: error, first, or last. Default: last. + --chunk-rows N CSV rows per yearly NetCDF builder chunk. + --write-time-chunk N Time steps per NetCDF write slice. + --max-memory-gb GB Builder allocation guard. Default: 470. + --dry-run Print sbatch commands without submitting. + --no-check-db Do not require the DB path to exist before submit. +EOF +} + +die() { + echo "$1" >&2 + usage + exit 2 +} + +append_prediction_targets() { + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + PREDICTION_TARGET_LIST+=("$value") + fi + done +} + +append_post_process_prediction_targets() { + local raw value + raw="${1//,/ }" + for value in $raw; do + if [[ -n "$value" ]]; then + POST_PROCESS_PREDICTION_TARGET_LIST+=("$value") + fi + done +} + +resolve_checkpoint_path_for_check() { + local checkpoint_path="$1" + case "$checkpoint_path" in + /*) + echo "$checkpoint_path" + ;; + "~"|"~/"*) + echo "${checkpoint_path/#\~/$HOME}" + ;; + *) + echo "$RUN_PATH/$checkpoint_path" + ;; + esac +} + +YEAR="${YEAR:-}" +MODE="${MODE:-parallel}" +POST_PROCESS="${POST_PROCESS:-1}" +DRY_RUN="${DRY_RUN:-0}" +CHECK_DB="${CHECK_DB:-1}" +RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" +CHECKPOINT_PATH="${CHECKPOINT_PATH:-checkpoint-11.pth}" +LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" +DATA_ROOT="${DATA_ROOT:-/home/l/luislara/links/projects/aip-pal/luislara/ep/data}" +DB_PATH="${DB_PATH:-}" +DB_START_YEAR="${DB_START_YEAR:-}" +DB_LABEL="${DB_LABEL:-}" +PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" +PREDICTION_TARGET_LIST=() +append_prediction_targets "$PREDICTION_TARGETS_ENV" +POST_PROCESS_PREDICTION_TARGETS_ENV="${POST_PROCESS_PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" +POST_PROCESS_PREDICTION_TARGET_LIST=() +append_post_process_prediction_targets "$POST_PROCESS_PREDICTION_TARGETS_ENV" +NUM_SHARDS="${NUM_SHARDS:-4}" +POST_OUTPUT_PATH="${POST_OUTPUT_PATH:-${YEAR_OUTPUT_PATH:-${OUTPUT_PATH:-}}}" +OVERWRITE_YEAR_NC=0 +PUSH_TARGET="${PUSH_TARGET:-drive}" +DESTINATION_PATH="${DESTINATION_PATH:-}" +HF_REPO_ID="${HF_REPO_ID:-}" +PATH_IN_REPO="${PATH_IN_REPO:-}" +REVISION="${REVISION:-${HF_REVISION:-}}" +COMMIT_MESSAGE="${HF_COMMIT_MESSAGE:-}" +CREATE_REPO="${CREATE_REPO:-1}" +PRIVATE="${PRIVATE:-0}" +RCLONE_REMOTE="${RCLONE_REMOTE:-}" +DRIVE_DIR="${DRIVE_DIR:-}" +REMOTE_DIR="${REMOTE_DIR:-}" +REMOTE_PATH="${REMOTE_PATH:-}" +RCLONE_ARGS=() +NETCDF_DUPLICATE_POLICY="${NETCDF_DUPLICATE_POLICY:-last}" +CHUNK_ROWS="${BUILD_YEAR_CHUNK_ROWS:-}" +WRITE_TIME_CHUNK="${BUILD_YEAR_WRITE_TIME_CHUNK:-}" +MAX_MEMORY_GB="${BUILD_YEAR_MAX_MEMORY_GB:-470}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --year) + [[ $# -ge 2 ]] || die "--year requires a value." + YEAR="$2" + shift 2 + ;; + --year=*) + YEAR="${1#*=}" + shift + ;; + --db-path) + [[ $# -ge 2 ]] || die "--db-path requires a value." + DB_PATH="$2" + shift 2 + ;; + --db-path=*) + DB_PATH="${1#*=}" + shift + ;; + --db-start-year) + [[ $# -ge 2 ]] || die "--db-start-year requires a value." + DB_START_YEAR="$2" + shift 2 + ;; + --db-start-year=*) + DB_START_YEAR="${1#*=}" + shift + ;; + --db-label) + [[ $# -ge 2 ]] || die "--db-label requires a value." + DB_LABEL="$2" + shift 2 + ;; + --db-label=*) + DB_LABEL="${1#*=}" + shift + ;; + --data-root) + [[ $# -ge 2 ]] || die "--data-root requires a value." + DATA_ROOT="$2" + shift 2 + ;; + --data-root=*) + DATA_ROOT="${1#*=}" + shift + ;; + --run-path) + [[ $# -ge 2 ]] || die "--run-path requires a value." + RUN_PATH="$2" + shift 2 + ;; + --run-path=*) + RUN_PATH="${1#*=}" + shift + ;; + --checkpoint-path) + [[ $# -ge 2 ]] || die "--checkpoint-path requires a value." + CHECKPOINT_PATH="$2" + shift 2 + ;; + --checkpoint-path=*) + CHECKPOINT_PATH="${1#*=}" + shift + ;; + --prediction-targets) + shift + PREDICTION_TARGET_LIST=() + while [[ $# -gt 0 && "$1" != --* ]]; do + append_prediction_targets "$1" + shift + done + [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." + ;; + --prediction-targets=*) + PREDICTION_TARGET_LIST=() + append_prediction_targets "${1#*=}" + [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." + shift + ;; + --post-process-prediction-targets|--post-prediction-targets) + shift + POST_PROCESS_PREDICTION_TARGET_LIST=() + while [[ $# -gt 0 && "$1" != --* ]]; do + append_post_process_prediction_targets "$1" + shift + done + [[ "${#POST_PROCESS_PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--post-process-prediction-targets requires at least one target." + ;; + --post-process-prediction-targets=*|--post-prediction-targets=*) + POST_PROCESS_PREDICTION_TARGET_LIST=() + append_post_process_prediction_targets "${1#*=}" + [[ "${#POST_PROCESS_PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--post-process-prediction-targets requires at least one target." + shift + ;; + --num-shards) + [[ $# -ge 2 ]] || die "--num-shards requires a value." + NUM_SHARDS="$2" + shift 2 + ;; + --num-shards=*) + NUM_SHARDS="${1#*=}" + shift + ;; + --parallel) + MODE="parallel" + shift + ;; + --sequential) + MODE="sequential" + shift + ;; + --post-process) + POST_PROCESS=1 + shift + ;; + --skip-post-processing|--no-post-processing) + POST_PROCESS=0 + shift + ;; + --post-output-path|--year-output-path) + [[ $# -ge 2 ]] || die "$1 requires a value." + POST_OUTPUT_PATH="$2" + shift 2 + ;; + --post-output-path=*|--year-output-path=*) + POST_OUTPUT_PATH="${1#*=}" + shift + ;; + --overwrite-year-nc|--overwrite) + OVERWRITE_YEAR_NC=1 + shift + ;; + --to|--target|--push-target) + [[ $# -ge 2 ]] || die "$1 requires a value." + PUSH_TARGET="$2" + shift 2 + ;; + --to=*|--target=*|--push-target=*) + PUSH_TARGET="${1#*=}" + shift + ;; + --path) + [[ $# -ge 2 ]] || die "--path requires a value." + DESTINATION_PATH="$2" + shift 2 + ;; + --path=*) + DESTINATION_PATH="${1#*=}" + shift + ;; + --rclone-remote) + [[ $# -ge 2 ]] || die "--rclone-remote requires a value." + RCLONE_REMOTE="$2" + shift 2 + ;; + --rclone-remote=*) + RCLONE_REMOTE="${1#*=}" + shift + ;; + --drive-dir) + [[ $# -ge 2 ]] || die "--drive-dir requires a value." + DRIVE_DIR="$2" + shift 2 + ;; + --drive-dir=*) + DRIVE_DIR="${1#*=}" + shift + ;; + --remote-dir) + [[ $# -ge 2 ]] || die "--remote-dir requires a value." + REMOTE_DIR="$2" + shift 2 + ;; + --remote-dir=*) + REMOTE_DIR="${1#*=}" + shift + ;; + --remote-path) + [[ $# -ge 2 ]] || die "--remote-path requires a value." + REMOTE_PATH="$2" + shift 2 + ;; + --remote-path=*) + REMOTE_PATH="${1#*=}" + shift + ;; + --rclone-arg) + [[ $# -ge 2 ]] || die "--rclone-arg requires a value." + RCLONE_ARGS+=("$2") + shift 2 + ;; + --rclone-arg=*) + RCLONE_ARGS+=("${1#*=}") + shift + ;; + --hf-repo-id) + [[ $# -ge 2 ]] || die "--hf-repo-id requires a value." + HF_REPO_ID="$2" + shift 2 + ;; + --hf-repo-id=*) + HF_REPO_ID="${1#*=}" + shift + ;; + --path-in-repo) + [[ $# -ge 2 ]] || die "--path-in-repo requires a value." + PATH_IN_REPO="$2" + shift 2 + ;; + --path-in-repo=*) + PATH_IN_REPO="${1#*=}" + shift + ;; + --revision) + [[ $# -ge 2 ]] || die "--revision requires a value." + REVISION="$2" + shift 2 + ;; + --revision=*) + REVISION="${1#*=}" + shift + ;; + --commit-message) + [[ $# -ge 2 ]] || die "--commit-message requires a value." + COMMIT_MESSAGE="$2" + shift 2 + ;; + --commit-message=*) + COMMIT_MESSAGE="${1#*=}" + shift + ;; + --private) + PRIVATE=1 + shift + ;; + --skip-create-repo) + CREATE_REPO=0 + shift + ;; + --netcdf-duplicate-policy) + [[ $# -ge 2 ]] || die "--netcdf-duplicate-policy requires a value." + NETCDF_DUPLICATE_POLICY="$2" + shift 2 + ;; + --netcdf-duplicate-policy=*) + NETCDF_DUPLICATE_POLICY="${1#*=}" + shift + ;; + --chunk-rows) + [[ $# -ge 2 ]] || die "--chunk-rows requires a value." + CHUNK_ROWS="$2" + shift 2 + ;; + --chunk-rows=*) + CHUNK_ROWS="${1#*=}" + shift + ;; + --write-time-chunk) + [[ $# -ge 2 ]] || die "--write-time-chunk requires a value." + WRITE_TIME_CHUNK="$2" + shift 2 + ;; + --write-time-chunk=*) + WRITE_TIME_CHUNK="${1#*=}" + shift + ;; + --max-memory-gb) + [[ $# -ge 2 ]] || die "--max-memory-gb requires a value." + MAX_MEMORY_GB="$2" + shift 2 + ;; + --max-memory-gb=*) + MAX_MEMORY_GB="${1#*=}" + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --no-check-db) + CHECK_DB=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -z "$YEAR" ]]; then + YEAR="$1" + shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +[[ -n "$YEAR" ]] || die "YEAR is required." +[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" + +case "$MODE" in + parallel|sequential) + ;; + *) + die "MODE must be parallel or sequential, got: $MODE" + ;; +esac + +case "${POST_PROCESS,,}" in + 1|true|yes) + POST_PROCESS=1 + ;; + 0|false|no) + POST_PROCESS=0 + ;; + *) + die "POST_PROCESS must be 1/0, true/false, or yes/no, got: $POST_PROCESS" + ;; +esac + +PUSH_TARGET="${PUSH_TARGET,,}" +case "$PUSH_TARGET" in + drive|hf) + ;; + *) + die "--to/--target must be drive or hf, got: $PUSH_TARGET" + ;; +esac + +[[ "$NUM_SHARDS" =~ ^[1-9][0-9]*$ ]] || die "--num-shards must be a positive integer." +[[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "PREDICTION_TARGETS must contain at least one target." +[[ "${#POST_PROCESS_PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "POST_PROCESS_PREDICTION_TARGETS must contain at least one target." + +case "$NETCDF_DUPLICATE_POLICY" in + error|first|last) + ;; + *) + die "--netcdf-duplicate-policy must be error, first, or last." + ;; +esac + +case "$CREATE_REPO" in + 0|1) + ;; + *) + die "CREATE_REPO must be 0 or 1, got: $CREATE_REPO" + ;; +esac + +case "$PRIVATE" in + 0|1) + ;; + *) + die "PRIVATE must be 0 or 1, got: $PRIVATE" + ;; +esac + +year_number=$((10#$YEAR)) +if [[ -n "$DB_START_YEAR" ]]; then + [[ "$DB_START_YEAR" =~ ^[0-9]{4}$ ]] || die "DB_START_YEAR must be a four-digit year, got: $DB_START_YEAR" + db_start_number=$((10#$DB_START_YEAR)) +else + if (( year_number % 2 == 0 )); then + db_start_number="$year_number" + else + db_start_number=$((year_number - 1)) + fi +fi + +if [[ -z "$DB_LABEL" ]]; then + db_end_number=$((db_start_number + 1)) + DB_LABEL="${db_start_number}_${db_end_number}" +fi + +if [[ -z "$DB_PATH" ]]; then + DB_PATH="${DATA_ROOT}/${DB_LABEL}/era5_${DB_LABEL}.db" +fi + +if [[ "$CHECK_DB" != "0" && ! -f "$DB_PATH" ]]; then + if [[ "$DRY_RUN" == "1" ]]; then + echo "WARNING: ERA5 DB not found: $DB_PATH" >&2 + else + echo "ERA5 DB not found: $DB_PATH" >&2 + echo "Use --db-path, --db-start-year, --db-label, or --data-root to point at the two-year DB." >&2 + exit 2 + fi +fi + +if [[ "$DRY_RUN" != "1" ]]; then + command -v sbatch >/dev/null || { + echo "sbatch not found in PATH; cannot submit ERA5 jobs." >&2 + exit 2 + } + [[ -f scripts/era5/run_multi_gpu.sh ]] || { + echo "Child job script not found: scripts/era5/run_multi_gpu.sh" >&2 + exit 2 + } + if [[ "$POST_PROCESS" == "1" ]]; then + [[ -f scripts/era5/post_processing.sh ]] || { + echo "Post-processing job script not found: scripts/era5/post_processing.sh" >&2 + exit 2 + } + fi + [[ -n "${SCRATCH:-}" ]] || { + echo "SCRATCH is not set; scripts/era5/run_multi_gpu.sh needs it to activate the environment." >&2 + exit 2 + } + [[ -f "$SCRATCH/env/ecoperceiver/bin/activate" ]] || { + echo "EcoPerceiver environment activation script not found: $SCRATCH/env/ecoperceiver/bin/activate" >&2 + exit 2 + } + [[ -d "$RUN_PATH" ]] || { + echo "Run path not found: $RUN_PATH" >&2 + exit 2 + } + [[ -n "$CHECKPOINT_PATH" ]] || { + echo "CHECKPOINT_PATH must not be empty." >&2 + exit 2 + } + checkpoint_path_for_check="$(resolve_checkpoint_path_for_check "$CHECKPOINT_PATH")" + [[ -f "$checkpoint_path_for_check" ]] || { + echo "Checkpoint not found: $checkpoint_path_for_check" >&2 + exit 2 + } +fi + +mkdir -p "$LOG_DIR" + +starts=("${YEAR}-01-01" "${YEAR}-04-01" "${YEAR}-07-01" "${YEAR}-10-01") +ends=("${YEAR}-03-31" "${YEAR}-06-30" "${YEAR}-09-30" "${YEAR}-12-31") + +PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" +POST_PROCESS_PREDICTION_TARGETS_VALUE="${POST_PROCESS_PREDICTION_TARGET_LIST[*]}" +export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" +export POST_PROCESS_PREDICTION_TARGETS="$POST_PROCESS_PREDICTION_TARGETS_VALUE" +export CHECKPOINT_PATH + +previous_dependency_id="" +dependency_ids=() +year_shard_dirs=() +echo "Submitting ERA5 one-year inference for $YEAR in $MODE mode." +echo "Two-year DB label: $DB_LABEL" +echo "DB path: $DB_PATH" +echo "Run path: $RUN_PATH" +echo "Checkpoint path: $CHECKPOINT_PATH" +echo "Inference prediction targets: $PREDICTION_TARGETS_VALUE" +if [[ "$POST_PROCESS" == "1" ]]; then + echo "Post-processing: enabled after all four quarter jobs finish successfully." + echo "Post-processing upload target: $PUSH_TARGET" + echo "Post-processing prediction targets: $POST_PROCESS_PREDICTION_TARGETS_VALUE" + echo "Post-processing expected rank shards per quarter: $NUM_SHARDS" + if [[ -n "$POST_OUTPUT_PATH" ]]; then + echo "Post-processing yearly output path: $POST_OUTPUT_PATH" + fi +else + echo "Post-processing: disabled." +fi + +for chunk_index in "${!starts[@]}"; do + initial_date="${starts[$chunk_index]}" + final_date="${ends[$chunk_index]}" + date_tag="${initial_date//-/}_to_${final_date//-/}" + job_name="run-mgpu-${YEAR}-q$((chunk_index + 1))" + log_base="$LOG_DIR/run_multi_gpu_${date_tag}" + log_out="${log_base}.out" + log_err="${log_base}.error" + shard_dir="$RUN_PATH/eval/.era5_predictions_${date_tag}_multi_gpu_shards" + year_shard_dirs+=("$shard_dir") + + sbatch_args=( + --parsable + --job-name "$job_name" + --output "$log_out" + --error "$log_err" + --export=ALL,RUN_PATH="$RUN_PATH",DB_PATH="$DB_PATH",INITIAL_DATE="$initial_date",FINAL_DATE="$final_date",LOG_DIR="$LOG_DIR",GPU_LOG_OUT="$log_out",GPU_LOG_ERR="$log_err",SHARD_DIR="$shard_dir" + ) + + if [[ "$MODE" == "sequential" && -n "$previous_dependency_id" ]]; then + sbatch_args+=(--dependency="afterok:$previous_dependency_id") + fi + + if [[ "$DRY_RUN" == "1" ]]; then + printf 'DRY RUN: PREDICTION_TARGETS=%q CHECKPOINT_PATH=%q sbatch' "$PREDICTION_TARGETS" "$CHECKPOINT_PATH" + printf ' %q' "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh + printf '\n' + job_id="dryrun-$((chunk_index + 1))" + else + job_id="$(sbatch "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh)" + fi + + dependency_id="${job_id%%;*}" + dependency_ids+=("$dependency_id") + echo "Chunk $((chunk_index + 1)): $initial_date to $final_date -> $job_id" + previous_dependency_id="$dependency_id" +done + +if [[ "$POST_PROCESS" == "1" ]]; then + [[ "${#dependency_ids[@]}" -eq 4 ]] || die "Expected four quarter job IDs for post-processing dependency." + [[ "${#year_shard_dirs[@]}" -eq 4 ]] || die "Expected four quarter shard directories for post-processing." + + dependency_list="$(IFS=:; echo "${dependency_ids[*]}")" + post_dependency="afterok:$dependency_list" + post_job_name="post-era5-${YEAR}" + post_log_base="$LOG_DIR/post_processing_${YEAR}" + post_log_out="${post_log_base}.out" + post_log_err="${post_log_base}.error" + + post_args=( + --year "$YEAR" + --run-path "$RUN_PATH" + --input-dir "$RUN_PATH/eval" + --shard-dirs "${year_shard_dirs[@]}" + --num-shards "$NUM_SHARDS" + --prediction-targets "${POST_PROCESS_PREDICTION_TARGET_LIST[@]}" + --netcdf-duplicate-policy "$NETCDF_DUPLICATE_POLICY" + --to "$PUSH_TARGET" + ) + + if [[ -n "$POST_OUTPUT_PATH" ]]; then + post_args+=(--output-path "$POST_OUTPUT_PATH") + fi + if [[ -n "$DESTINATION_PATH" ]]; then + post_args+=(--path "$DESTINATION_PATH") + fi + if [[ -n "$RCLONE_REMOTE" ]]; then + post_args+=(--rclone-remote "$RCLONE_REMOTE") + fi + if [[ -n "$DRIVE_DIR" ]]; then + post_args+=(--drive-dir "$DRIVE_DIR") + fi + if [[ -n "$REMOTE_DIR" ]]; then + post_args+=(--remote-dir "$REMOTE_DIR") + fi + if [[ -n "$REMOTE_PATH" ]]; then + post_args+=(--remote-path "$REMOTE_PATH") + fi + for rclone_arg in "${RCLONE_ARGS[@]}"; do + post_args+=(--rclone-arg "$rclone_arg") + done + if [[ -n "$HF_REPO_ID" ]]; then + post_args+=(--hf-repo-id "$HF_REPO_ID") + fi + if [[ -n "$PATH_IN_REPO" ]]; then + post_args+=(--path-in-repo "$PATH_IN_REPO") + fi + if [[ -n "$REVISION" ]]; then + post_args+=(--revision "$REVISION") + fi + if [[ -n "$COMMIT_MESSAGE" ]]; then + post_args+=(--commit-message "$COMMIT_MESSAGE") + fi + if [[ -n "$CHUNK_ROWS" ]]; then + post_args+=(--chunk-rows "$CHUNK_ROWS") + fi + if [[ -n "$WRITE_TIME_CHUNK" ]]; then + post_args+=(--write-time-chunk "$WRITE_TIME_CHUNK") + fi + if [[ -n "$MAX_MEMORY_GB" ]]; then + post_args+=(--max-memory-gb "$MAX_MEMORY_GB") + fi + if [[ "$OVERWRITE_YEAR_NC" == "1" ]]; then + post_args+=(--overwrite) + fi + if [[ "$PRIVATE" == "1" ]]; then + post_args+=(--private) + fi + if [[ "$CREATE_REPO" == "0" ]]; then + post_args+=(--skip-create-repo) + fi + + post_sbatch_args=( + --parsable + --job-name "$post_job_name" + --output "$post_log_out" + --error "$post_log_err" + --dependency="$post_dependency" + --export=ALL,RUN_PATH="$RUN_PATH",LOG_DIR="$LOG_DIR",POST_LOG_OUT="$post_log_out",POST_LOG_ERR="$post_log_err" + ) + + if [[ "$DRY_RUN" == "1" ]]; then + printf 'DRY RUN: sbatch' + printf ' %q' "${post_sbatch_args[@]}" scripts/era5/post_processing.sh "${post_args[@]}" + printf '\n' + post_job_id="dryrun-post" + else + post_job_id="$(sbatch "${post_sbatch_args[@]}" scripts/era5/post_processing.sh "${post_args[@]}")" + fi + + echo "Post-processing dependency: $post_dependency" + echo "Post-processing job: $post_job_id" +fi diff --git a/scripts/era5/submit_year.sh b/scripts/era5/submit_year.sh deleted file mode 100755 index 62630bf..0000000 --- a/scripts/era5/submit_year.sh +++ /dev/null @@ -1,353 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -cd "$REPO_ROOT" - -usage() { - cat >&2 < /2016_2017/era5_2016_2017.db - 2017 -> /2016_2017/era5_2016_2017.db - -Options: - --year YEAR Year to infer. Positional YEAR is also accepted. - --db-path PATH Explicit ERA5 SQLite database path. - --db-start-year YEAR First year in the two-year database. - --db-label LABEL Database label, for example 2016_2017. - --data-root PATH Root directory containing date-range DB folders. - --run-path PATH EcoPerceiver run directory. - --checkpoint-path PATH Checkpoint path relative to run-path, or absolute. - --prediction-targets LIST Prediction targets, comma or space separated. - --parallel Submit all quarters immediately. Default. - --sequential Chain quarter jobs with afterok dependencies. - --post-format csv|netcdf Final post-processing output format. Default: netcdf. - --dry-run Print sbatch commands without submitting. - --no-check-db Do not require the DB path to exist before submit. -EOF -} - -die() { - echo "$1" >&2 - usage - exit 2 -} - -append_prediction_targets() { - local raw value - raw="${1//,/ }" - for value in $raw; do - if [[ -n "$value" ]]; then - PREDICTION_TARGET_LIST+=("$value") - fi - done -} - -append_merge_prediction_targets() { - local raw value - raw="${1//,/ }" - for value in $raw; do - if [[ -n "$value" ]]; then - MERGE_PREDICTION_TARGET_LIST+=("$value") - fi - done -} - -resolve_checkpoint_path_for_check() { - local checkpoint_path="$1" - case "$checkpoint_path" in - /*) - echo "$checkpoint_path" - ;; - "~"|"~/"*) - echo "${checkpoint_path/#\~/$HOME}" - ;; - *) - echo "$RUN_PATH/$checkpoint_path" - ;; - esac -} - -YEAR="${YEAR:-}" -MODE="${MODE:-parallel}" -POST_FORMAT="${POST_FORMAT:-netcdf}" -DRY_RUN="${DRY_RUN:-0}" -CHECK_DB="${CHECK_DB:-1}" -RUN_PATH="${RUN_PATH:-experiments/runs/final_v2_3e-06_ws_l128_f12_e32_c32_o0.3_wcswcswcswcsssss_CC/seed_0}" -CHECKPOINT_PATH="${CHECKPOINT_PATH:-checkpoint-11.pth}" -LOG_DIR="${LOG_DIR:-/scratch/l/luislara/EcoPerceiver/logs}" -DATA_ROOT="${DATA_ROOT:-/home/l/luislara/links/projects/aip-pal/luislara/ep/data}" -DB_PATH="${DB_PATH:-}" -DB_START_YEAR="${DB_START_YEAR:-}" -DB_LABEL="${DB_LABEL:-}" -PREDICTION_TARGETS_ENV="${PREDICTION_TARGETS:-pred_NEE pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" -PREDICTION_TARGET_LIST=() -append_prediction_targets "$PREDICTION_TARGETS_ENV" -MERGE_PREDICTION_TARGETS_ENV="${MERGE_PREDICTION_TARGETS:-pred_GPP_DT pred_RECO_DT pred_FCH4 pred_LE}" -MERGE_PREDICTION_TARGET_LIST=() -append_merge_prediction_targets "$MERGE_PREDICTION_TARGETS_ENV" - -while [[ $# -gt 0 ]]; do - case "$1" in - --year) - [[ $# -ge 2 ]] || die "--year requires a value." - YEAR="$2" - shift 2 - ;; - --year=*) - YEAR="${1#*=}" - shift - ;; - --db-path) - [[ $# -ge 2 ]] || die "--db-path requires a value." - DB_PATH="$2" - shift 2 - ;; - --db-path=*) - DB_PATH="${1#*=}" - shift - ;; - --db-start-year) - [[ $# -ge 2 ]] || die "--db-start-year requires a value." - DB_START_YEAR="$2" - shift 2 - ;; - --db-start-year=*) - DB_START_YEAR="${1#*=}" - shift - ;; - --db-label) - [[ $# -ge 2 ]] || die "--db-label requires a value." - DB_LABEL="$2" - shift 2 - ;; - --db-label=*) - DB_LABEL="${1#*=}" - shift - ;; - --data-root) - [[ $# -ge 2 ]] || die "--data-root requires a value." - DATA_ROOT="$2" - shift 2 - ;; - --data-root=*) - DATA_ROOT="${1#*=}" - shift - ;; - --run-path) - [[ $# -ge 2 ]] || die "--run-path requires a value." - RUN_PATH="$2" - shift 2 - ;; - --run-path=*) - RUN_PATH="${1#*=}" - shift - ;; - --checkpoint-path) - [[ $# -ge 2 ]] || die "--checkpoint-path requires a value." - CHECKPOINT_PATH="$2" - shift 2 - ;; - --checkpoint-path=*) - CHECKPOINT_PATH="${1#*=}" - shift - ;; - --prediction-targets) - shift - PREDICTION_TARGET_LIST=() - while [[ $# -gt 0 && "$1" != --* ]]; do - append_prediction_targets "$1" - shift - done - [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." - ;; - --prediction-targets=*) - PREDICTION_TARGET_LIST=() - append_prediction_targets "${1#*=}" - [[ "${#PREDICTION_TARGET_LIST[@]}" -gt 0 ]] || die "--prediction-targets requires at least one target." - shift - ;; - --parallel) - MODE="parallel" - shift - ;; - --sequential) - MODE="sequential" - shift - ;; - --post-format) - [[ $# -ge 2 ]] || die "--post-format requires a value." - POST_FORMAT="$2" - shift 2 - ;; - --post-format=*) - POST_FORMAT="${1#*=}" - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --no-check-db) - CHECK_DB=0 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - if [[ -z "$YEAR" ]]; then - YEAR="$1" - shift - else - die "Unknown argument: $1" - fi - ;; - esac -done - -[[ -n "$YEAR" ]] || die "YEAR is required." -[[ "$YEAR" =~ ^[0-9]{4}$ ]] || die "YEAR must be a four-digit year, got: $YEAR" - -case "$MODE" in - parallel|sequential) - ;; - *) - die "MODE must be parallel or sequential, got: $MODE" - ;; -esac - -case "$POST_FORMAT" in - csv|netcdf) - ;; - *) - die "POST_FORMAT must be csv or netcdf, got: $POST_FORMAT" - ;; -esac - -year_number=$((10#$YEAR)) -if [[ -n "$DB_START_YEAR" ]]; then - [[ "$DB_START_YEAR" =~ ^[0-9]{4}$ ]] || die "DB_START_YEAR must be a four-digit year, got: $DB_START_YEAR" - db_start_number=$((10#$DB_START_YEAR)) -else - if (( year_number % 2 == 0 )); then - db_start_number="$year_number" - else - db_start_number=$((year_number - 1)) - fi -fi - -if [[ -z "$DB_LABEL" ]]; then - db_end_number=$((db_start_number + 1)) - DB_LABEL="${db_start_number}_${db_end_number}" -fi - -if [[ -z "$DB_PATH" ]]; then - DB_PATH="${DATA_ROOT}/${DB_LABEL}/era5_${DB_LABEL}.db" -fi - -if [[ "$CHECK_DB" != "0" && ! -f "$DB_PATH" ]]; then - if [[ "$DRY_RUN" == "1" ]]; then - echo "WARNING: ERA5 DB not found: $DB_PATH" >&2 - else - echo "ERA5 DB not found: $DB_PATH" >&2 - echo "Use --db-path, --db-start-year, --db-label, or --data-root to point at the two-year DB." >&2 - exit 2 - fi -fi - -if [[ "$DRY_RUN" != "1" ]]; then - command -v sbatch >/dev/null || { - echo "sbatch not found in PATH; cannot submit ERA5 jobs." >&2 - exit 2 - } - [[ -f scripts/era5/run_multi_gpu.sh ]] || { - echo "Child job script not found: scripts/era5/run_multi_gpu.sh" >&2 - exit 2 - } - [[ -n "${SCRATCH:-}" ]] || { - echo "SCRATCH is not set; scripts/era5/run_multi_gpu.sh needs it to activate the environment." >&2 - exit 2 - } - [[ -f "$SCRATCH/env/ecoperceiver/bin/activate" ]] || { - echo "EcoPerceiver environment activation script not found: $SCRATCH/env/ecoperceiver/bin/activate" >&2 - exit 2 - } - [[ -d "$RUN_PATH" ]] || { - echo "Run path not found: $RUN_PATH" >&2 - exit 2 - } - [[ -n "$CHECKPOINT_PATH" ]] || { - echo "CHECKPOINT_PATH must not be empty." >&2 - exit 2 - } - checkpoint_path_for_check="$(resolve_checkpoint_path_for_check "$CHECKPOINT_PATH")" - [[ -f "$checkpoint_path_for_check" ]] || { - echo "Checkpoint not found: $checkpoint_path_for_check" >&2 - exit 2 - } -fi - -mkdir -p "$LOG_DIR" - -starts=("${YEAR}-01-01" "${YEAR}-04-01" "${YEAR}-07-01" "${YEAR}-10-01") -ends=("${YEAR}-03-31" "${YEAR}-06-30" "${YEAR}-09-30" "${YEAR}-12-31") - -PREDICTION_TARGETS_VALUE="${PREDICTION_TARGET_LIST[*]}" -MERGE_PREDICTION_TARGETS_VALUE="${MERGE_PREDICTION_TARGET_LIST[*]}" -export PREDICTION_TARGETS="$PREDICTION_TARGETS_VALUE" -export MERGE_PREDICTION_TARGETS="$MERGE_PREDICTION_TARGETS_VALUE" -export CHECKPOINT_PATH - -previous_dependency_id="" -echo "Submitting ERA5 one-year inference for $YEAR in $MODE mode." -echo "Two-year DB label: $DB_LABEL" -echo "DB path: $DB_PATH" -echo "Run path: $RUN_PATH" -echo "Checkpoint path: $CHECKPOINT_PATH" -echo "Post format: $POST_FORMAT" -echo "Inference prediction targets: $PREDICTION_TARGETS_VALUE" -echo "Merge prediction targets: $MERGE_PREDICTION_TARGETS_VALUE" - -for chunk_index in "${!starts[@]}"; do - initial_date="${starts[$chunk_index]}" - final_date="${ends[$chunk_index]}" - date_tag="${initial_date//-/}_to_${final_date//-/}" - job_name="run-mgpu-${YEAR}-q$((chunk_index + 1))" - log_base="$LOG_DIR/run_multi_gpu_${date_tag}" - log_out="${log_base}.out" - log_err="${log_base}.error" - output_csv="$RUN_PATH/eval/era5_predictions_${date_tag}.csv" - shard_dir="$RUN_PATH/eval/.era5_predictions_${date_tag}_multi_gpu_shards" - - sbatch_args=( - --parsable - --job-name "$job_name" - --output "$log_out" - --error "$log_err" - --export=ALL,RUN_PATH="$RUN_PATH",DB_PATH="$DB_PATH",INITIAL_DATE="$initial_date",FINAL_DATE="$final_date",POST_FORMAT="$POST_FORMAT",LOG_DIR="$LOG_DIR",GPU_LOG_OUT="$log_out",GPU_LOG_ERR="$log_err",OUTPUT_CSV="$output_csv",SHARD_DIR="$shard_dir",OUTPUT_PATH= - ) - - if [[ "$MODE" == "sequential" && -n "$previous_dependency_id" ]]; then - sbatch_args+=(--dependency="afterok:$previous_dependency_id") - fi - - if [[ "$DRY_RUN" == "1" ]]; then - printf 'DRY RUN: PREDICTION_TARGETS=%q MERGE_PREDICTION_TARGETS=%q CHECKPOINT_PATH=%q sbatch' "$PREDICTION_TARGETS" "$MERGE_PREDICTION_TARGETS" "$CHECKPOINT_PATH" - printf ' %q' "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh - printf '\n' - job_id="dryrun-$((chunk_index + 1))" - else - job_id="$(sbatch "${sbatch_args[@]}" scripts/era5/run_multi_gpu.sh)" - fi - - dependency_id="${job_id%%;*}" - previous_dependency_id="$dependency_id" - echo "Chunk $((chunk_index + 1)): $initial_date to $final_date -> $job_id" -done From a0c1ddb65dd4a2901fe645e4bcdb66aa3399d8a5 Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Tue, 30 Jun 2026 16:43:06 -0400 Subject: [PATCH 13/14] minor bug fixed in post --- scripts/era5/post_processing.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/era5/post_processing.sh b/scripts/era5/post_processing.sh index ee21e95..45322e6 100755 --- a/scripts/era5/post_processing.sh +++ b/scripts/era5/post_processing.sh @@ -27,6 +27,7 @@ done exit 2 } cd "$REPO_ROOT" +SCRIPT_DIR="$REPO_ROOT/scripts/era5" if [[ -f "$REPO_ROOT/.env" ]]; then set -a From 00b3941aa9ed83e49c9538c419fd6ad68b2514df Mon Sep 17 00:00:00 2001 From: Luis Lara Date: Tue, 11 Aug 2026 14:31:54 -0400 Subject: [PATCH 14/14] second wave of inferences started --- era5_pipeline/pipeline.sh | 6 +++--- era5_pipeline/pipeline_config.yml | 17 ++++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/era5_pipeline/pipeline.sh b/era5_pipeline/pipeline.sh index 99a2d90..f87a47b 100755 --- a/era5_pipeline/pipeline.sh +++ b/era5_pipeline/pipeline.sh @@ -4,9 +4,9 @@ #SBATCH --mem=128G #SBATCH --cpus-per-task=8 #SBATCH --time=24:00:00 -#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j_3.out -#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j_3.error -#SBATCH --job-name=era5-pipeline3 +#SBATCH --output=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j_5.out +#SBATCH --error=/home/l/luislara/links/scratch/EcoPerceiver/era5_pipeline/logs/pipeline_%j_5.error +#SBATCH --job-name=era5-pipeline5 #SBATCH --account=aip-pal set -euo pipefail diff --git a/era5_pipeline/pipeline_config.yml b/era5_pipeline/pipeline_config.yml index d9fde70..312abe9 100644 --- a/era5_pipeline/pipeline_config.yml +++ b/era5_pipeline/pipeline_config.yml @@ -9,12 +9,15 @@ pipeline: dry_run: false python: null -# start_date: "2016-01-01" -# end_date: "2017-12-31" -# start_date: "2014-01-01" -# end_date: "2015-12-31" -start_date: "2012-01-01" -end_date: "2013-12-31" + +# start_date: "2024-01-01" +# end_date: "2025-12-31" +# start_date: "2022-01-01" +# end_date: "2023-12-31" +# start_date: "2020-01-01" +# end_date: "2021-12-31" +start_date: "2018-01-01" +end_date: "2019-12-31" paths: data_root: /home/l/luislara/links/projects/aip-pal/luislara/ep/data @@ -101,7 +104,7 @@ download_modis: authenticate: false assign_igbp_from_modis: - modis_path: 201701011200C1.tiff + modis_path: 201301011200C1.tiff table: coord_data only_null: false write: true