diff --git a/.gitignore b/.gitignore index 0b99ee8..1d32263 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ copilot-instructions.md IEM Data *.parquet + +#Run Output +/runs diff --git a/docs/dev-log/.authors.yml b/docs/dev-log/.authors.yml index 519a4e6..26acaa3 100644 --- a/docs/dev-log/.authors.yml +++ b/docs/dev-log/.authors.yml @@ -3,4 +3,9 @@ authors: name: keenanjohnson description: Creator avatar: https://avatars.githubusercontent.com/u/2559382?v=4 + anoushkagupta: + name: anoushkagupta + description: Contributor + avatar: https://avatars.githubusercontent.com/u/52936225?v=4 + diff --git a/docs/dev-log/posts/2026-06-28-starting-thoughts.md b/docs/dev-log/posts/2026-06-28-starting-thoughts.md new file mode 100644 index 0000000..553a854 --- /dev/null +++ b/docs/dev-log/posts/2026-06-28-starting-thoughts.md @@ -0,0 +1,22 @@ +--- +date: 2026-06-28 +authors: + - anoushkagupta +--- + +# Introduction + +Hey! I'm a Anoushka a new Engineer working on Loaf! As both a frequent "accidently stepped out in the rain" victim and a former Civil/Environmental Engineer, the value of better local weather predictions feels very clear and I'm excited to be dusting off some ML skills for this project! + +## The Research +My initial thought on the research was how refreshingly accessible it was. The intent, reasoning, and procedure were clearly laid out and I was able to grasp the base logic within a read. I was suprised that they were able to get such an improvement in RMSE from just a transformer model, signaling the value in this work. I'm curious to how this research could apply to regions with more stable weather conditions than the Northeast (I'm from LA the land of constant perfect weather). + +I also looked into what other literature was other on ML models localizing weather and found ClimaX by Microsoft and some more neural based models. I'd love to avoid a black box model and maintain understandibility as much as possible, but also am curious how other models perform. + +## Next Steps +After diving into both the research and the code, I want to: +- **Recreate modeling with a local dataset** - I'm based in the DMV and am from LA and am curious how the model handels both area +- **Finish full training cycle** - close some gaps in the code regarding training/model assesment +- **Try other model types to drive accuracy** - based on other literature see how other models compare for same dataset +- **Make codebase more user-friendly** - the goal is to eventually make the model self-service and I have a few thoughts on how to make the UX as friendly as possible + diff --git a/plan/dev-plan-ml-pipeline.md b/plan/dev-plan-ml-pipeline.md index 3cb9b23..19b0e11 100644 --- a/plan/dev-plan-ml-pipeline.md +++ b/plan/dev-plan-ml-pipeline.md @@ -555,10 +555,10 @@ class LOAFWeatherEntity(WeatherEntity): ### Milestone 3: Training Pipeline - [x] Create PyTorch Dataset class (`loaf/data/loaders/dataset.py`) -- [ ] Implement training loop (`loaf/training/trainer.py`) -- [ ] Implement evaluation metrics (`loaf/training/evaluate.py`) -- [ ] Write `scripts/train.py` CLI -- [ ] **Verify:** Train on 1 month data, loss decreases, metrics improve +- [x] Implement training loop (`loaf/training/trainer.py`) +- [x] Implement evaluation metrics (`loaf/training/evaluate.py`) +- [x] Write `scripts/train.py` CLI +- [x] **Verify:** Train on downloaded Arlington data (MPNN and ViT), loss decreases, metrics improve ### Milestone 4: Inference & API - [ ] Implement predictor (`loaf/inference/predictor.py`) diff --git a/pyproject.toml b/pyproject.toml index 95ab5ca..f95f30c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ # Utilities "tqdm>=4.65.0", "python-dotenv>=1.0.0", + "pyarrow>=14.0.0", ] [project.optional-dependencies] diff --git a/software/config/arlington.yaml b/software/config/arlington.yaml new file mode 100644 index 0000000..bbb675c --- /dev/null +++ b/software/config/arlington.yaml @@ -0,0 +1,114 @@ +# LOAF Configuration for Arlington Region +# Based on LocalizedWeather paper methodology + +region: + name: arlington + description: "Arlington and DMV Region" + # Spatial bounds (WGS84) + lat_min: 38.0 + lat_max: 39.5 + lon_min: -78 + lon_max: -76 + +data: + # Historical window for input features + back_hrs: 24 + # Forecast horizon + lead_hrs: 48 + # Lead times to predict (hours from model init) + lead_times: [6, 12, 18, 24, 30, 36, 42, 48] + + hrrr: + # Variables to download (GRIB2 search pattern) + # TMP:2 m - 2 meter temperature + # DPT:2 m - 2 meter dewpoint + # UGRD:10 m - 10 meter U wind component + # VGRD:10 m - 10 meter V wind component + variables: "(?:TMP:2 m|DPT:2 m|UGRD:10 m|VGRD:10 m)" + # Maximum forecast lead time to download per run + max_lead_hr: 18 + # Output directory (relative to project root) + output_dir: "data/hrrr" + + era5: + # ERA5 single-level variables + variables: + - "10m_u_component_of_wind" + - "10m_v_component_of_wind" + - "2m_temperature" + - "2m_dewpoint_temperature" + output_dir: "data/era5" + + iem: + stations: + - "DCA" # Reagan National + - "IAD" # Dulles + - "BWI" # Baltimore-Washington + - "HEF" # Manassas Regional + - "MRB" # Eastern WV + # Iowa Environmental Mesonet - ASOS/AWOS stations + # No registration required, good for prototyping + # Variables: tmpc (temp C), dwpc (dewpoint C), sknt (wind knots), drct (wind dir) + variables: + - "tmpc" + - "dwpc" + - "sknt" + - "drct" + output_dir: "data/iem" + format: "parquet" + + madis: + # MADIS station networks for DMV + # Requires registration: https://madis.ncep.noaa.gov/data_application.shtml + networks: + - "ASOS" + - "VDOT" # Virginia Department of Transportation + - "CWOP" # Citizen Weather Observer Program + - "RAWS" # Remote Automated Weather Stations + # Quality control - only use values with these flags + qc_flags: ["S", "V"] # S=Standard, V=Verified + # Maximum wind speed to include (filter outliers) + max_wind_speed: 50.0 + output_dir: "data/madis" + +model: + # Model architecture settings (match LocalizedWeather paper) + hidden_dim: 128 + num_gnn_layers: 2 + num_transformer_layers: 5 + num_heads: 3 + dropout: 0.1 + + # Graph construction + graph: + # K-nearest neighbors for station graph + k_neighbors: 5 + # Maximum edge distance in km + max_distance: 100.0 + +training: + epochs: 100 + batch_size: 64 + learning_rate: 1.0e-4 + weight_decay: 1.0e-4 + # Fraction of data for validation + val_split: 0.15 + # Early stopping patience (epochs) + patience: 10 + # Gradient clipping + max_grad_norm: 1.0 + # Random seed for reproducibility + seed: 42 + +inference: + # Output directory for predictions + output_dir: "data/forecasts" + # Update interval for operational mode (seconds) + update_interval: 3600 # 1 hour + +homeassistant: + # REST API settings + host: "0.0.0.0" + port: 5000 + # Sensor update interval (seconds) + scan_interval: 3600 diff --git a/software/loaf/data/download/era5.py b/software/loaf/data/download/era5.py index d069a96..18328ac 100644 --- a/software/loaf/data/download/era5.py +++ b/software/loaf/data/download/era5.py @@ -234,46 +234,71 @@ def load_era5_month(file_path: str | Path) -> xr.Dataset: return ds +def _load_era5_settings_from_config(config_path: str) -> dict: + """Extract ERA5-relevant settings from a LOAF config file.""" + from loaf.config import load_config + + cfg = load_config(config_path) + era5_cfg = cfg.get("data", {}).get("era5", {}) + region_cfg = cfg.get("region", {}) + return { + "variables": era5_cfg.get("variables"), + "output_dir": era5_cfg.get("output_dir"), + "lat_min": region_cfg.get("lat_min"), + "lat_max": region_cfg.get("lat_max"), + "lon_min": region_cfg.get("lon_min"), + "lon_max": region_cfg.get("lon_max"), + } + + def main() -> None: """CLI entry point for ERA5 download.""" parser = argparse.ArgumentParser( description="Download ERA5 reanalysis data for a specified region and time range." ) + parser.add_argument( + "--config", + "-c", + default=None, + help="Path to a LOAF YAML config file (e.g. config/arlington.yaml). " + "Provides region bounds, variables, and output settings. " + "CLI flags override config values when both are provided.", + ) parser.add_argument( "--output-dir", "-o", - default="data/era5", + default=None, help="Output directory for NetCDF files (default: data/era5)", ) parser.add_argument( "--variables", nargs="+", - default=DEFAULT_VARIABLES, + default=None, help="ERA5 variables to download", ) parser.add_argument( "--lat-min", type=float, - default=SEATTLE_BOUNDS["lat_min"], + default=None, help="Minimum latitude (default: 46.5 for Seattle)", ) parser.add_argument( "--lat-max", type=float, - default=SEATTLE_BOUNDS["lat_max"], + default=None, help="Maximum latitude (default: 49.0 for Seattle)", ) parser.add_argument( "--lon-min", type=float, - default=SEATTLE_BOUNDS["lon_min"], + default=None, help="Minimum longitude (default: -124.0 for Seattle)", ) parser.add_argument( "--lon-max", type=float, - default=SEATTLE_BOUNDS["lon_max"], + default=None, help="Maximum longitude (default: -121.0 for Seattle)", ) parser.add_argument( @@ -317,7 +342,26 @@ def main() -> None: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) - output_dir = Path(args.output_dir) + # Load config defaults, then let CLI args override + cfg_settings: dict = {} + if args.config: + cfg_settings = _load_era5_settings_from_config(args.config) + logger.info(f"Loaded config from {args.config}") + + output_dir = Path(args.output_dir or cfg_settings.get("output_dir") or "data/era5") + variables = args.variables or cfg_settings.get("variables") or DEFAULT_VARIABLES + lat_min = args.lat_min if args.lat_min is not None else cfg_settings.get( + "lat_min", SEATTLE_BOUNDS["lat_min"] + ) + lat_max = args.lat_max if args.lat_max is not None else cfg_settings.get( + "lat_max", SEATTLE_BOUNDS["lat_max"] + ) + lon_min = args.lon_min if args.lon_min is not None else cfg_settings.get( + "lon_min", SEATTLE_BOUNDS["lon_min"] + ) + lon_max = args.lon_max if args.lon_max is not None else cfg_settings.get( + "lon_max", SEATTLE_BOUNDS["lon_max"] + ) if args.year and args.month: # Single month download @@ -326,11 +370,11 @@ def main() -> None: args.year, args.month, filename, - args.variables, - args.lat_min, - args.lat_max, - args.lon_min, - args.lon_max, + variables, + lat_min, + lat_max, + lon_min, + lon_max, ) elif args.year: @@ -338,11 +382,11 @@ def main() -> None: download_era5_year( args.year, output_dir, - args.variables, - args.lat_min, - args.lat_max, - args.lon_min, - args.lon_max, + variables, + lat_min, + lat_max, + lon_min, + lon_max, ) elif args.start_year and args.end_year: @@ -353,11 +397,11 @@ def main() -> None: args.end_year, args.end_month, output_dir, - args.variables, - args.lat_min, - args.lat_max, - args.lon_min, - args.lon_max, + variables, + lat_min, + lat_max, + lon_min, + lon_max, ) else: diff --git a/software/loaf/data/download/hrrr.py b/software/loaf/data/download/hrrr.py index 80cb23e..180587e 100644 --- a/software/loaf/data/download/hrrr.py +++ b/software/loaf/data/download/hrrr.py @@ -234,51 +234,79 @@ def download_hrrr_range( return saved_files +def _load_hrrr_settings_from_config(config_path: str) -> dict: + """Extract HRRR-relevant settings from a LOAF config file.""" + from loaf.config import load_config + + cfg = load_config(config_path) + hrrr_cfg = cfg.get("data", {}).get("hrrr", {}) + region_cfg = cfg.get("region", {}) + return { + "var_list": hrrr_cfg.get("variables"), + "max_lead_hr": hrrr_cfg.get("max_lead_hr"), + "output_dir": hrrr_cfg.get("output_dir"), + # Region bounds are stored in -180/180 format in the config, matching + # the CLI flags below (converted to 0-360 later for HRRR/Herbie). + "lat_min": region_cfg.get("lat_min"), + "lat_max": region_cfg.get("lat_max"), + "lon_min": region_cfg.get("lon_min"), + "lon_max": region_cfg.get("lon_max"), + } + + def main() -> None: """CLI entry point for HRRR download.""" parser = argparse.ArgumentParser( description="Download HRRR forecast data for a specified region and date range." ) + parser.add_argument( + "--config", + "-c", + default=None, + help="Path to a LOAF YAML config file (e.g. config/arlington.yaml). " + "Provides region bounds, variables, and output settings. " + "CLI flags override config values when both are provided.", + ) parser.add_argument( "--output-dir", "-o", - default="data/hrrr", + default=None, help="Output directory for NetCDF files (default: data/hrrr)", ) parser.add_argument( "--var-list", - default=DEFAULT_VARIABLES, + default=None, help=f"GRIB2 variable search pattern (default: {DEFAULT_VARIABLES})", ) parser.add_argument( "--lat-min", type=float, - default=SEATTLE_BOUNDS["lat_min"], + default=None, help="Minimum latitude (default: 46.5 for Seattle)", ) parser.add_argument( "--lat-max", type=float, - default=SEATTLE_BOUNDS["lat_max"], + default=None, help="Maximum latitude (default: 49.0 for Seattle)", ) parser.add_argument( "--lon-min", type=float, - default=SEATTLE_BOUNDS["lon_min"] - 360, + default=None, help="Minimum longitude in -180 to 180 format (default: -124.0 for Seattle)", ) parser.add_argument( "--lon-max", type=float, - default=SEATTLE_BOUNDS["lon_max"] - 360, + default=None, help="Maximum longitude in -180 to 180 format (default: -121.0 for Seattle)", ) parser.add_argument( "--max-lead-hr", type=int, - default=18, + default=None, help="Maximum forecast lead time in hours (default: 18)", ) parser.add_argument( @@ -305,24 +333,49 @@ def main() -> None: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) + # Load config defaults, then let CLI args override + cfg_settings: dict = {} + if args.config: + cfg_settings = _load_hrrr_settings_from_config(args.config) + logger.info(f"Loaded config from {args.config}") + + output_dir = args.output_dir or cfg_settings.get("output_dir") or "data/hrrr" + var_list = args.var_list or cfg_settings.get("var_list") or DEFAULT_VARIABLES + max_lead_hr = ( + args.max_lead_hr if args.max_lead_hr is not None + else cfg_settings.get("max_lead_hr") or 18 + ) + lat_min = args.lat_min if args.lat_min is not None else cfg_settings.get( + "lat_min", SEATTLE_BOUNDS["lat_min"] + ) + lat_max = args.lat_max if args.lat_max is not None else cfg_settings.get( + "lat_max", SEATTLE_BOUNDS["lat_max"] + ) + raw_lon_min = args.lon_min if args.lon_min is not None else cfg_settings.get( + "lon_min", SEATTLE_BOUNDS["lon_min"] - 360 + ) + raw_lon_max = args.lon_max if args.lon_max is not None else cfg_settings.get( + "lon_max", SEATTLE_BOUNDS["lon_max"] - 360 + ) + # Convert longitude from -180/180 to 0/360 for HRRR - lon_min = args.lon_min + 360 if args.lon_min < 0 else args.lon_min - lon_max = args.lon_max + 360 if args.lon_max < 0 else args.lon_max + lon_min = raw_lon_min + 360 if raw_lon_min < 0 else raw_lon_min + lon_max = raw_lon_max + 360 if raw_lon_max < 0 else raw_lon_max if args.date: # Single day download date = datetime.strptime(args.date, "%Y-%m-%d") - output_dir = Path(args.output_dir) + output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) dataset = download_hrrr_daily( date, - args.var_list, - args.lat_min, - args.lat_max, + var_list, + lat_min, + lat_max, lon_min, lon_max, - args.max_lead_hr, + max_lead_hr, ) if dataset is not None: @@ -340,13 +393,13 @@ def main() -> None: download_hrrr_range( start_date, end_date, - args.output_dir, - args.var_list, - args.lat_min, - args.lat_max, + output_dir, + var_list, + lat_min, + lat_max, lon_min, lon_max, - args.max_lead_hr, + max_lead_hr, ) else: diff --git a/software/loaf/data/download/iem.py b/software/loaf/data/download/iem.py index 820227e..1290ebb 100644 --- a/software/loaf/data/download/iem.py +++ b/software/loaf/data/download/iem.py @@ -12,8 +12,10 @@ import argparse import io import logging +import time from datetime import datetime from pathlib import Path +from typing import Any import pandas as pd import requests @@ -75,6 +77,78 @@ DEFAULT_VARIABLES = ["tmpc", "dwpc", "sknt", "drct"] +def _bbox_of_geometry(geometry: dict) -> tuple[float, float, float, float]: + """Compute (lon_min, lon_max, lat_min, lat_max) for a GeoJSON geometry.""" + lons: list[float] = [] + lats: list[float] = [] + + def _walk(coords: Any) -> None: + if isinstance(coords[0], (int, float)): + lons.append(coords[0]) + lats.append(coords[1]) + else: + for c in coords: + _walk(c) + + _walk(geometry["coordinates"]) + return min(lons), max(lons), min(lats), max(lats) + + +def _find_asos_networks_for_bbox( + lat_min: float, + lat_max: float, + lon_min: float, + lon_max: float, +) -> list[str]: + """Find IEM ASOS network IDs whose coverage overlaps a bounding box. + + IEM does not expose a single global "ASOS" network - stations are split + into per-country/per-state networks (e.g. "VA_ASOS", "MD_ASOS"). This + queries IEM's network list and returns the ASOS network IDs whose + coverage polygon overlaps the given region, so callers can fetch stations + from just those networks. + + Args: + lat_min: Minimum latitude. + lat_max: Maximum latitude. + lon_min: Minimum longitude. + lon_max: Maximum longitude. + + Returns: + List of matching network IDs (e.g. ["VA_ASOS", "MD_ASOS", "DC_ASOS"]). + """ + url = "https://mesonet.agron.iastate.edu/geojson/networks.geojson" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + data = response.json() + except Exception as e: + logger.error(f"Failed to fetch network list: {e}") + return [] + + matches = [] + for feature in data.get("features", []): + network_id = feature.get("id", "") + geometry = feature.get("geometry") + if not network_id.endswith("_ASOS") or not geometry: + continue + + try: + net_lon_min, net_lon_max, net_lat_min, net_lat_max = _bbox_of_geometry(geometry) + except (KeyError, IndexError, TypeError): + continue + + if net_lon_max < lon_min or net_lon_min > lon_max: + continue + if net_lat_max < lat_min or net_lat_min > lat_max: + continue + + matches.append(network_id) + + return matches + + def get_available_stations( lat_min: float = SEATTLE_BOUNDS["lat_min"], lat_max: float = SEATTLE_BOUNDS["lat_max"], @@ -92,40 +166,44 @@ def get_available_stations( Returns: DataFrame with station metadata (id, name, lat, lon, elevation). """ - # IEM station metadata endpoint - url = "https://mesonet.agron.iastate.edu/geojson/network/ASOS.geojson" - - try: - response = requests.get(url, timeout=30) - response.raise_for_status() - data = response.json() - except Exception as e: - logger.error(f"Failed to fetch station list: {e}") + networks = _find_asos_networks_for_bbox(lat_min, lat_max, lon_min, lon_max) + if not networks: + logger.warning("No ASOS networks found covering the requested region") return pd.DataFrame() stations = [] - for feature in data.get("features", []): - props = feature.get("properties", {}) - coords = feature.get("geometry", {}).get("coordinates", [None, None]) - - lon, lat = coords[0], coords[1] - if lon is None or lat is None: + for network_id in networks: + url = f"https://mesonet.agron.iastate.edu/geojson/network/{network_id}.geojson" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + data = response.json() + except Exception as e: + logger.error(f"Failed to fetch station list for {network_id}: {e}") continue - # Filter to bounding box - if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max: - stations.append( - { - "station_id": props.get("sid", ""), - "name": props.get("sname", ""), - "lat": lat, - "lon": lon, - "elevation": props.get("elevation", None), - } - ) + for feature in data.get("features", []): + props = feature.get("properties", {}) + coords = feature.get("geometry", {}).get("coordinates", [None, None]) + + lon, lat = coords[0], coords[1] + if lon is None or lat is None: + continue + + # Filter to bounding box + if lat_min <= lat <= lat_max and lon_min <= lon <= lon_max: + stations.append( + { + "station_id": props.get("sid", ""), + "name": props.get("sname", ""), + "lat": lat, + "lon": lon, + "elevation": props.get("elevation", None), + } + ) df = pd.DataFrame(stations) - logger.info(f"Found {len(df)} ASOS stations in region") + logger.info(f"Found {len(df)} ASOS stations in region across {len(networks)} network(s)") return df @@ -196,6 +274,7 @@ def download_iem_stations( start_date: datetime, end_date: datetime, variables: list[str] = DEFAULT_VARIABLES, + request_delay: float = 1.0, ) -> pd.DataFrame: """Download IEM ASOS data for multiple stations. @@ -204,13 +283,17 @@ def download_iem_stations( start_date: Start date for data request. end_date: End date for data request (inclusive). variables: List of variables to download. + request_delay: Seconds to wait between station requests, to avoid + IEM's rate limiting (HTTP 429). Returns: DataFrame with observation data from all stations. """ all_data = [] - for station in stations: + for i, station in enumerate(stations): + if i > 0 and request_delay > 0: + time.sleep(request_delay) logger.info(f"Downloading {station}...") df = download_iem_station(station, start_date, end_date, variables) if df is not None and not df.empty: @@ -345,6 +428,7 @@ def download_iem_range( stations: list[str] | None = None, variables: list[str] = DEFAULT_VARIABLES, format: str = "parquet", + request_delay: float = 1.0, ) -> list[Path]: """Download IEM data for a date range, saving one file per month. @@ -355,6 +439,8 @@ def download_iem_range( stations: List of stations to download. If None, uses PNW_STATIONS. variables: List of variables to download. format: Output format ("parquet" or "csv"). + request_delay: Seconds to wait between station requests, to avoid + IEM's rate limiting (HTTP 429). Returns: List of paths to saved files. @@ -399,6 +485,7 @@ def download_iem_range( month_start, datetime(month_end.year, month_end.month, month_end.day), variables, + request_delay, ) if not df.empty: @@ -418,28 +505,55 @@ def download_iem_range( return saved_files +def _load_iem_settings_from_config(config_path: str) -> dict[str, Any]: + """Extract IEM-relevant settings from a LOAF config file.""" + from loaf.config import load_config + + cfg = load_config(config_path) + iem_cfg = cfg.get("data", {}).get("iem", {}) + region_cfg = cfg.get("region", {}) + return { + "stations": iem_cfg.get("stations"), + "variables": iem_cfg.get("variables"), + "output_dir": iem_cfg.get("output_dir"), + "format": iem_cfg.get("format"), + "lat_min": region_cfg.get("lat_min"), + "lat_max": region_cfg.get("lat_max"), + "lon_min": region_cfg.get("lon_min"), + "lon_max": region_cfg.get("lon_max"), + } + + def main() -> None: """CLI entry point for IEM download.""" parser = argparse.ArgumentParser( description="Download ASOS/AWOS observation data from Iowa Environmental Mesonet." ) + parser.add_argument( + "--config", + "-c", + default=None, + help="Path to a LOAF YAML config file (e.g. config/arlington.yaml). " + "Provides region bounds, station list, and output settings. " + "CLI flags override config values when both are provided.", + ) parser.add_argument( "--output-dir", "-o", - default="data/iem", + default=None, help="Output directory for data files (default: data/iem)", ) parser.add_argument( "--stations", nargs="+", default=None, - help="Station IDs to download (default: PNW stations)", + help="Station IDs to download. Overrides config stations list.", ) parser.add_argument( "--variables", nargs="+", - default=DEFAULT_VARIABLES, + default=None, help="Variables to download (default: tmpc,dwpc,sknt,drct)", ) parser.add_argument( @@ -457,13 +571,20 @@ def main() -> None: parser.add_argument( "--format", choices=["parquet", "csv"], - default="parquet", + default=None, help="Output format (default: parquet)", ) parser.add_argument( "--list-stations", action="store_true", - help="List available stations in PNW region and exit", + help="List available stations and exit. Uses region bounds from --config if provided.", + ) + parser.add_argument( + "--rate-limit-delay", + type=float, + default=1.0, + help="Seconds to wait between per-station requests, to avoid IEM's " + "rate limiting (default: 1.0)", ) args = parser.parse_args() @@ -474,27 +595,55 @@ def main() -> None: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) + # Load config defaults, then let CLI args override + cfg_settings: dict[str, Any] = {} + if args.config: + cfg_settings = _load_iem_settings_from_config(args.config) + logger.info(f"Loaded config from {args.config}") + + stations = args.stations or cfg_settings.get("stations") + variables = args.variables or cfg_settings.get("variables") or DEFAULT_VARIABLES + output_dir = args.output_dir or cfg_settings.get("output_dir") or "data/iem" + fmt = args.format or cfg_settings.get("format") or "parquet" + + lat_min = cfg_settings.get("lat_min", SEATTLE_BOUNDS["lat_min"]) + lat_max = cfg_settings.get("lat_max", SEATTLE_BOUNDS["lat_max"]) + lon_min = cfg_settings.get("lon_min", SEATTLE_BOUNDS["lon_min"]) + lon_max = cfg_settings.get("lon_max", SEATTLE_BOUNDS["lon_max"]) + if args.list_stations: - print("Predefined PNW stations:") - for station in PNW_STATIONS: - print(f" {station}") - print("\nQuerying IEM for all available stations in region...") - df = get_available_stations() + if stations: + print("Stations from config/CLI:") + for s in stations: + print(f" {s}") + print(f"\nQuerying IEM for all available stations in region " + f"({lat_min}–{lat_max}°N, {lon_min}–{lon_max}°E)...") + df = get_available_stations(lat_min, lat_max, lon_min, lon_max) if not df.empty: print(f"\nFound {len(df)} stations:") print(df.to_string(index=False)) return + # If no stations provided by config or CLI, fall back to bounding-box discovery + if not stations: + logger.info("No stations specified — discovering from region bounds") + station_df = get_available_stations(lat_min, lat_max, lon_min, lon_max) + if station_df.empty: + logger.error("No stations found in region — pass --stations or check bounds") + return + stations = station_df["station_id"].tolist() + start_date = datetime.strptime(args.start_date, "%Y-%m-%d") end_date = datetime.strptime(args.end_date, "%Y-%m-%d") download_iem_range( start_date, end_date, - args.output_dir, - args.stations, - args.variables, - args.format, + output_dir, + stations, + variables, + fmt, + args.rate_limit_delay, ) diff --git a/software/loaf/data/loaders/dataset.py b/software/loaf/data/loaders/dataset.py index 0d9cbb4..2f0b200 100644 --- a/software/loaf/data/loaders/dataset.py +++ b/software/loaf/data/loaders/dataset.py @@ -6,14 +6,16 @@ Adapted from LocalizedWeather MixData.py. """ +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any +import numpy as np import pandas as pd import torch from dateutil import rrule -from torch.utils.data import Dataset +from torch.utils.data import DataLoader, Dataset, Subset from .era5 import ERA5Loader from .hrrr import HRRRLoader @@ -27,33 +29,41 @@ class WeatherDataset(Dataset): Combines gridded data (ERA5 or HRRR) with station observations for training the GNN + ViT model. + Each sample splits a time window into a historical input (the + ``back_hrs`` hours up to and including "now") and a multi-horizon + target: ``target_vars`` at each offset in ``lead_times`` hours + after "now". + Args: year: Year of data to load. back_hrs: Number of historical hours for input. - lead_hours: Number of forecast hours (prediction horizon). station_metadata: StationMetadata instance. station_loader: IEMLoader or MADISLoader instance. + lead_times: Forecast horizons in hours (e.g. [6, 12, 24, 48]). grid_loader: ERA5Loader or HRRRLoader instance (optional). - station_vars: List of station variables to use. - grid_vars: List of grid variables to use. - normalize: Whether to apply normalization. + station_vars: List of station variables to use as input. + grid_vars: List of grid variables to use as input. + target_vars: List of variables to predict (default: u, v wind). + normalize: Whether to apply min-max normalization. """ def __init__( self, year: int, back_hrs: int, - lead_hours: int, station_metadata: StationMetadata, station_loader: IEMLoader, + lead_times: list[int] | None = None, grid_loader: ERA5Loader | HRRRLoader | None = None, station_vars: list[str] | None = None, grid_vars: list[str] | None = None, + target_vars: list[str] | None = None, normalize: bool = True, ): self.year = year self.back_hrs = back_hrs - self.lead_hours = lead_hours + self.lead_times = sorted(lead_times or [48]) + self.lead_hours = self.lead_times[-1] self.station_metadata = station_metadata self.station_loader = station_loader @@ -62,6 +72,13 @@ def __init__( # Default variables self.station_vars = station_vars or ["u", "v", "temp", "dewpoint"] self.grid_vars = grid_vars or ["u", "v", "temp", "dewpoint"] + self.target_vars = target_vars or ["u", "v"] + + # Vars to fetch from the station loader: inputs + any targets not + # already covered by station_vars. + self._fetch_vars = list( + dict.fromkeys([*self.station_vars, *self.target_vars]) + ) self.normalize = normalize @@ -87,7 +104,7 @@ def _generate_timeline(self, year: int) -> pd.DatetimeIndex: def _compute_statistics(self) -> None: """Compute normalization statistics.""" - self.station_stats = self.station_loader.compute_statistics(self.station_vars) + self.station_stats = self.station_loader.compute_statistics(self._fetch_vars) if self.grid_loader is not None: self.grid_stats = self.grid_loader.compute_statistics(self.grid_vars) @@ -126,6 +143,41 @@ def _normalize_var( return (values - min_val) / (max_val - min_val + eps) + def _stack_vars( + self, + data: dict[str, torch.Tensor], + variables: list[str], + t_start: int, + t_end: int, + source: str, + ) -> torch.Tensor: + """Stack a list of variables into a (n_entities, n_time, n_vars) tensor.""" + values = [] + for var in variables: + v = data[var][:, t_start:t_end] + if self.normalize: + v = self._normalize_var(v, var, source) + values.append(v) + return torch.stack(values, dim=-1) + + def _stack_masks( + self, + data: dict[str, torch.Tensor], + variables: list[str], + t_start: int, + t_end: int, + ) -> torch.Tensor: + """Stack "is_real" masks for a list of variables into (n_entities, n_time, n_vars).""" + masks = [] + for var in variables: + key = f"{var}_is_real" + if key in data: + m = data[key][:, t_start:t_end] + else: + m = torch.ones_like(data[var][:, t_start:t_end]) + masks.append(m) + return torch.stack(masks, dim=-1) + def __len__(self) -> int: """Number of samples in the dataset.""" # We need back_hrs of history and lead_hours of future @@ -134,21 +186,21 @@ def __len__(self) -> int: def __getitem__(self, index: int) -> dict[str, Any]: """Get a training sample. + The time window covers ``back_hrs`` hours of history (ending at + "now") plus ``max(lead_times)`` hours of future targets. + Args: index: Sample index. Returns: Dictionary containing: - - time: Tensor of timestamps - - station_lon, station_lat: Normalized station coordinates - - k_edge_index: Station graph edges - - {var}: Station observations for each variable - - {var}_is_real: Mask of real vs filled values - - ext_{var}: Grid data for each variable (if grid_loader provided) - - grid_lon, grid_lat: Normalized grid coordinates - - ex2m_edge_index: Grid-to-station edges + - madis_x: Station input (n_stations, back_hrs, n_station_vars) + - madis_lon, madis_lat: Normalized station coordinates (n_stations, 1) + - edge_index: Station-to-station graph edges (2, n_edges) + - target: (n_stations, n_lead_times, n_target_vars) + - target_mask: "is_real" mask matching target, same shape + - ex_x, ex_lon, ex_lat, edge_index_e2m: Grid inputs (if grid_loader given) """ - # Time window start_idx = index end_idx = index + self.back_hrs + self.lead_hours @@ -156,67 +208,52 @@ def __getitem__(self, index: int) -> dict[str, Any]: time_start = time_sel[0] time_end = time_sel[-1] - # Build sample dictionary - sample = {} - - # Timestamps - sample["time"] = torch.tensor( - [t.value for t in time_sel], dtype=torch.long - ) - - # Station coordinates (normalized) - station_lons = self.station_metadata.lons - station_lats = self.station_metadata.lats - norm_lons, norm_lats = self._normalize_coords(station_lons, station_lats) - sample["station_lon"] = norm_lons - sample["station_lat"] = norm_lats + sample: dict[str, Any] = {} - # Station graph - sample["k_edge_index"] = self.station_metadata.get_k_edge_index() + # --- Station input: the back_hrs hours up to and including "now" --- + station_data = self.station_loader.get_sample(time_start, time_end, self._fetch_vars) - # Station observations - station_data = self.station_loader.get_sample( - time_start, time_end, self.station_vars + sample["madis_x"] = self._stack_vars( + station_data, self.station_vars, 0, self.back_hrs, "station" ) - for var in self.station_vars: - if var in station_data: - values = station_data[var] - if self.normalize: - values = self._normalize_var(values, var, "station") - sample[var] = values + norm_lons, norm_lats = self._normalize_coords( + self.station_metadata.lons, self.station_metadata.lats + ) + sample["madis_lon"] = norm_lons.unsqueeze(-1) + sample["madis_lat"] = norm_lats.unsqueeze(-1) + sample["edge_index"] = self.station_metadata.get_k_edge_index() + + # --- Targets: target_vars at each lead time, offset from "now" --- + target_steps = [] + target_mask_steps = [] + for lead_hr in self.lead_times: + # "now" is relative index back_hrs - 1; lead_hr hours after that. + t_idx = self.back_hrs - 1 + lead_hr + target_steps.append( + self._stack_vars( + station_data, self.target_vars, t_idx, t_idx + 1, "station" + ).squeeze(1) + ) + target_mask_steps.append( + self._stack_masks(station_data, self.target_vars, t_idx, t_idx + 1).squeeze(1) + ) - # Include is_real mask - is_real_var = f"{var}_is_real" - if is_real_var in station_data: - sample[is_real_var] = station_data[is_real_var] + sample["target"] = torch.stack(target_steps, dim=1) # (n_stations, n_lead_times, n_vars) + sample["target_mask"] = torch.stack(target_mask_steps, dim=1) - # Grid data (if available) + # --- Grid input (optional): historical window matching station input --- if self.grid_loader is not None: - grid_data = self.grid_loader.get_sample( - time_start, time_end, self.grid_vars - ) + grid_data = self.grid_loader.get_sample(time_start, time_end, self.grid_vars) - for var in self.grid_vars: - if var in grid_data: - values = grid_data[var] - if self.normalize: - values = self._normalize_var(values, var, "grid") - sample[f"ext_{var}"] = values + sample["ex_x"] = self._stack_vars(grid_data, self.grid_vars, 0, self.back_hrs, "grid") - # Grid coordinates grid_pos = self.grid_loader.get_node_positions() - grid_lons = grid_pos[:, 0] - grid_lats = grid_pos[:, 1] - norm_grid_lons, norm_grid_lats = self._normalize_coords( - grid_lons, grid_lats - ) - sample["grid_lon"] = norm_grid_lons - sample["grid_lat"] = norm_grid_lats + norm_grid_lons, norm_grid_lats = self._normalize_coords(grid_pos[:, 0], grid_pos[:, 1]) + sample["ex_lon"] = norm_grid_lons.unsqueeze(-1) + sample["ex_lat"] = norm_grid_lats.unsqueeze(-1) - # Grid-to-station edges (can be precomputed) - # For now, use simple KNN from grid to stations - sample["ex2m_edge_index"] = self._compute_grid_to_station_edges( + sample["edge_index_e2m"] = self._compute_grid_to_station_edges( grid_pos, self.station_metadata.positions ) @@ -267,44 +304,123 @@ def n_grid_nodes(self) -> int | None: return self.grid_loader.n_nodes return None + def usable_index_range(self) -> tuple[int, int]: + """Range [start, end) of sample indices safe to draw from. + + __len__ spans the whole calendar year regardless of how much of it + was actually downloaded or observed. Two failure modes otherwise + follow from that: + + - Station data is forward/backward-filled by IEMLoader, so it never + crashes, but a chronological train/val split over the full + __len__ range can put validation entirely past the last real + observation (or before the first), i.e. all-imputed targets and + meaningless masked metrics. + - Grid (HRRR/ERA5) data is NOT filled - it's exactly whatever was + downloaded. A sample whose input window falls outside the grid + loader's covered time range gets an empty array back and crashes + in get_sample()'s reshape. + + This bounds indices so every sample's targets land within real + station observations, and (if a grid_loader is set) every sample's + input window lands within the grid's downloaded time range. + """ + ds = self.station_loader.data + first_real_idx, last_real_idx = None, None + for var in self.target_vars: + key = f"{var}_is_real" + if key not in ds.data_vars: + continue + real_at_time = ds[key].values.max(axis=0) > 0 # any station real, per time + nonzero = real_at_time.nonzero()[0] + if len(nonzero) == 0: + continue + lo, hi = int(nonzero[0]), int(nonzero[-1]) + first_real_idx = lo if first_real_idx is None else min(first_real_idx, lo) + last_real_idx = hi if last_real_idx is None else max(last_real_idx, hi) + + if first_real_idx is None: + return (0, 0) + + min_lead, max_lead = self.lead_times[0], self.lead_times[-1] + start = max(0, first_real_idx - self.back_hrs + 1 - min_lead) + end = last_real_idx - self.back_hrs - max_lead + 2 # exclusive + + if self.grid_loader is not None: + grid_times = self.grid_loader.times + if len(grid_times) == 0: + return (0, 0) + + timeline_values = self.timeline.values + grid_start_idx = int(np.searchsorted(timeline_values, grid_times.min(), side="left")) + grid_end_idx = int(np.searchsorted(timeline_values, grid_times.max(), side="right")) - 1 + + # Input window [index, index + back_hrs) must lie within grid coverage. + # This is necessary but not sufficient if the grid has interior + # gaps (e.g. a few missing hours within an otherwise-covered + # range) - such samples can still raise a shape mismatch. + start = max(start, grid_start_idx) + end = min(end, grid_end_idx - self.back_hrs + 2) + + end = min(end, len(self)) + start = min(start, end) + return (max(0, start), max(0, end)) + + +@dataclass +class DatasetBundle: + """Train/val dataloaders plus the underlying dataset for introspection.""" + + train_loader: DataLoader + val_loader: DataLoader + dataset: WeatherDataset + def create_dataloaders( data_dir: str | Path, year: int, back_hrs: int = 24, - lead_hours: int = 48, + lead_times: list[int] | None = None, batch_size: int = 32, val_split: float = 0.15, - num_workers: int = 4, + num_workers: int = 0, lat_bounds: tuple[float, float] | None = None, lon_bounds: tuple[float, float] | None = None, + station_vars: list[str] | None = None, + grid_vars: list[str] | None = None, + target_vars: list[str] | None = None, + min_observations: int = 24, use_era5: bool = False, - use_hrrr: bool = True, -) -> tuple[torch.utils.data.DataLoader, torch.utils.data.DataLoader]: + use_hrrr: bool = False, +) -> DatasetBundle: """Create train and validation dataloaders. Args: data_dir: Base directory containing data subdirectories. year: Year of data to use. back_hrs: Number of historical hours. - lead_hours: Forecast horizon in hours. + lead_times: Forecast horizons in hours (default: [48]). batch_size: Batch size for training. val_split: Fraction of data for validation. num_workers: Number of dataloader workers. lat_bounds: Geographic bounds (lat_min, lat_max). lon_bounds: Geographic bounds (lon_min, lon_max). - use_era5: Whether to use ERA5 as grid data. - use_hrrr: Whether to use HRRR as grid data. + station_vars: Station variables to use as input. + grid_vars: Grid variables to use as input (if grid enabled). + target_vars: Variables to predict (default: u, v wind). + min_observations: Minimum real observations required to keep a station. + use_era5: Whether to fuse ERA5 as grid data. + use_hrrr: Whether to fuse HRRR as grid data. Returns: - Tuple of (train_loader, val_loader). + DatasetBundle with train_loader, val_loader, and the base dataset. """ data_dir = Path(data_dir) # Default Seattle bounds - if lat_bounds is None: + if lat_bounds is None or lat_bounds[0] is None: lat_bounds = (46.5, 49.0) - if lon_bounds is None: + if lon_bounds is None or lon_bounds[0] is None: lon_bounds = (-124.0, -121.0) # Load station metadata from IEM data @@ -312,8 +428,15 @@ def create_dataloaders( data_dir / "iem", lat_bounds=lat_bounds, lon_bounds=lon_bounds, + min_observations=min_observations, ) + if station_metadata.n_stations == 0: + raise ValueError( + f"No stations with >= {min_observations} observations found in " + f"{data_dir / 'iem'} within bounds {lat_bounds}, {lon_bounds}." + ) + # Load station observations station_loader = IEMLoader( data_dir / "iem", @@ -346,23 +469,38 @@ def create_dataloaders( dataset = WeatherDataset( year=year, back_hrs=back_hrs, - lead_hours=lead_hours, station_metadata=station_metadata, station_loader=station_loader, + lead_times=lead_times, grid_loader=grid_loader, + station_vars=station_vars, + grid_vars=grid_vars, + target_vars=target_vars, ) - # Split into train/val - n_samples = len(dataset) - n_val = int(n_samples * val_split) + # Restrict to samples with real (non-imputed) target coverage - and, if + # grid fusion is on, within the grid loader's downloaded time range too - + # so a chronological split doesn't push validation past the last real + # observation, and grid sampling doesn't hit undownloaded time (see + # usable_index_range() docstring). + start_idx, end_idx = dataset.usable_index_range() + n_samples = end_idx - start_idx + if n_samples < 2: + raise ValueError( + f"Not enough real target/grid coverage for year {year} to build a " + f"train/val split (back_hrs={back_hrs}, lead_hours={dataset.lead_hours}, " + f"usable_samples={n_samples}). Try a different --year, download more " + f"data, or reduce back_hrs/lead_times." + ) + + n_val = max(1, int(n_samples * val_split)) n_train = n_samples - n_val # Use contiguous split (later data for validation) - train_dataset = torch.utils.data.Subset(dataset, range(n_train)) - val_dataset = torch.utils.data.Subset(dataset, range(n_train, n_samples)) + train_dataset = Subset(dataset, range(start_idx, start_idx + n_train)) + val_dataset = Subset(dataset, range(start_idx + n_train, end_idx)) - # Create dataloaders - train_loader = torch.utils.data.DataLoader( + train_loader = DataLoader( train_dataset, batch_size=batch_size, shuffle=True, @@ -370,7 +508,7 @@ def create_dataloaders( pin_memory=True, ) - val_loader = torch.utils.data.DataLoader( + val_loader = DataLoader( val_dataset, batch_size=batch_size, shuffle=False, @@ -378,4 +516,4 @@ def create_dataloaders( pin_memory=True, ) - return train_loader, val_loader + return DatasetBundle(train_loader=train_loader, val_loader=val_loader, dataset=dataset) diff --git a/software/loaf/data/loaders/era5.py b/software/loaf/data/loaders/era5.py index 6996abb..d864a03 100644 --- a/software/loaf/data/loaders/era5.py +++ b/software/loaf/data/loaders/era5.py @@ -86,11 +86,16 @@ def _load_data(self) -> xr.Dataset: f"No ERA5 files found in {self.data_dir} for years {self.years}" ) - # Load all files (chunks=None to avoid requiring dask) + # Load eagerly and combine in memory - avoids open_mfdataset, which + # requires dask even when chunks=None in newer xarray versions. if len(files) == 1: ds = xr.open_dataset(files[0]) else: - ds = xr.open_mfdataset(files, combine="by_coords", chunks=None) + # override: per-file provenance attrs can differ across files and + # aren't needed after loading. + ds = xr.combine_by_coords( + [xr.open_dataset(f) for f in files], combine_attrs="override" + ) # Rename variables to standard names rename_map = {k: v for k, v in self.VARIABLE_RENAME.items() if k in ds.data_vars} diff --git a/software/loaf/data/loaders/hrrr.py b/software/loaf/data/loaders/hrrr.py index 7a30535..7849122 100644 --- a/software/loaf/data/loaders/hrrr.py +++ b/software/loaf/data/loaders/hrrr.py @@ -72,6 +72,7 @@ def __init__( # Lazy loading self._data: xr.Dataset | None = None self._node_positions: torch.Tensor | None = None + self._valid_mask: np.ndarray | None = None @property def data(self) -> xr.Dataset: @@ -97,11 +98,16 @@ def _load_data(self) -> xr.Dataset: f"No HRRR files found in {self.data_dir}" ) - # Load all files (chunks=None to avoid requiring dask) + # Load eagerly and combine in memory - avoids open_mfdataset, which + # requires dask even when chunks=None in newer xarray versions. if len(files) == 1: ds = xr.open_dataset(files[0]) else: - ds = xr.open_mfdataset(files, combine="by_coords", chunks=None) + # override: per-file GRIB provenance attrs (remote_grib, local_grib) + # differ across files and aren't needed after loading. + ds = xr.combine_by_coords( + [xr.open_dataset(f) for f in files], combine_attrs="override" + ) # Apply variable renaming based on what's available rename_map = {} @@ -117,6 +123,31 @@ def load_to_memory(self) -> None: """Load all data into memory for faster access.""" self._data = self.data.load() + def _get_valid_mask(self) -> np.ndarray: + """Boolean mask (n_nodes,) of grid cells with no NaN in any variable/time. + + HRRR's native grid is a rotated (Lambert Conformal) projection, so a + rectangular lat/lon bounding box doesn't align with it: cells inside + the requested box's (y, x) index rectangle but outside the true + lat/lon bounds come back as NaN from the download's spatial masking. + This is deterministic per region (same cells every time), so it's + computed once and used to exclude those cells everywhere - a single + NaN grid node would otherwise poison an entire batch through the + GNN's message passing. + """ + if self._valid_mask is not None: + return self._valid_mask + + mask = None + for var in self.data.data_vars: + values = self.data[var].values + spatial_size = values.shape[-2] * values.shape[-1] + node_valid = ~np.isnan(values).reshape(-1, spatial_size).any(axis=0) + mask = node_valid if mask is None else (mask & node_valid) + + self._valid_mask = mask if mask is not None else np.array([], dtype=bool) + return self._valid_mask + def get_node_positions(self) -> torch.Tensor: """Get (lon, lat) positions of all grid nodes. @@ -143,9 +174,9 @@ def get_node_positions(self) -> torch.Tensor: # Convert longitude back to -180 to 180 if needed lons = np.where(lons > 180, lons - 360, lons) - self._node_positions = torch.from_numpy( - np.stack([lons, lats], axis=-1).astype(np.float32) - ) + positions = np.stack([lons, lats], axis=-1).astype(np.float32) + positions = positions[self._get_valid_mask()] + self._node_positions = torch.from_numpy(positions) return self._node_positions def get_sample( @@ -192,6 +223,7 @@ def get_sample( elif values.ndim == 2: # (time, node) -> (node, time) values = values.T + values = values[self._get_valid_mask()] result[var] = torch.from_numpy(values) return result @@ -276,22 +308,8 @@ def get_sample_with_forecast( @property def n_nodes(self) -> int: - """Total number of grid nodes.""" - # Get shape from first variable - for var in self.data.data_vars: - shape = self.data[var].shape - # Find spatial dimensions (not time or step) - if "step" in self.data[var].dims and "time" in self.data[var].dims: - # (time, step, y, x) or (time, step, node) - if len(shape) == 4: - return shape[2] * shape[3] - return shape[2] - elif "time" in self.data[var].dims: - # (time, y, x) or (time, node) - if len(shape) == 3: - return shape[1] * shape[2] - return shape[1] - return 0 + """Total number of valid (non-NaN) grid nodes.""" + return int(self._get_valid_mask().sum()) @property def times(self) -> np.ndarray: diff --git a/software/loaf/training/__init__.py b/software/loaf/training/__init__.py index c998f6b..c58a7f8 100644 --- a/software/loaf/training/__init__.py +++ b/software/loaf/training/__init__.py @@ -1 +1,6 @@ """Training pipeline modules.""" + +from loaf.training.evaluate import RunningMetrics +from loaf.training.trainer import Trainer, TrainerState, build_model + +__all__ = ["Trainer", "TrainerState", "build_model", "RunningMetrics"] diff --git a/software/loaf/training/evaluate.py b/software/loaf/training/evaluate.py new file mode 100644 index 0000000..88f9317 --- /dev/null +++ b/software/loaf/training/evaluate.py @@ -0,0 +1,112 @@ +"""Evaluation metrics for weather forecasting models. + +All metrics are computed over masked, multi-horizon predictions of shape +(n_batch, n_stations, n_lead_times, n_target_vars) so that imputed +(non-real) observations don't distort training signal or reported scores. + +Adapted from LocalizedWeather EvaluateModel.py. +""" + +import torch + + +class RunningMetrics: + """Accumulates masked regression metrics across batches. + + Keeps running sums rather than storing every prediction, so an + epoch's worth of validation batches can be scored with O(1) memory. + + Args: + lead_times: Forecast horizons in hours, matching the target's + lead-time axis (used only to label the per-horizon breakdown). + target_vars: Names of the predicted variables, matching the + target's variable axis (used only to label the breakdown). + """ + + def __init__(self, lead_times: list[int], target_vars: list[str]): + self.lead_times = lead_times + self.target_vars = target_vars + self.reset() + + def reset(self) -> None: + """Reset all running sums.""" + n_lead, n_vars = len(self.lead_times), len(self.target_vars) + self._sum_sq = torch.zeros(n_lead, n_vars, dtype=torch.float64) + self._sum_abs = torch.zeros(n_lead, n_vars, dtype=torch.float64) + self._sum_mask = torch.zeros(n_lead, n_vars, dtype=torch.float64) + self._sum_sq_persistence = 0.0 + self._sum_mask_persistence = 0.0 + + def update( + self, + preds: torch.Tensor, + target: torch.Tensor, + mask: torch.Tensor, + persistence: torch.Tensor | None = None, + ) -> None: + """Accumulate stats from one batch. + + Args: + preds: Predictions (n_batch, n_stations, n_lead_times, n_target_vars). + target: Ground truth, same shape. + mask: 1.0 for real observations, 0.0 for imputed/missing, same shape. + persistence: Optional naive "no change from now" baseline predictions, + same shape, used to compute a skill score. + """ + preds = preds.detach() + target = target.detach() + mask = mask.detach() + + err = (preds - target) * mask + sq = (err ** 2).double() + ab = err.abs().double() + m = mask.double() + + # Sum over batch and station dims -> (n_lead_times, n_target_vars) + self._sum_sq += sq.sum(dim=(0, 1)).cpu() + self._sum_abs += ab.sum(dim=(0, 1)).cpu() + self._sum_mask += m.sum(dim=(0, 1)).cpu() + + if persistence is not None: + perr = (persistence.detach() - target) * mask + self._sum_sq_persistence += (perr ** 2).double().sum().item() + self._sum_mask_persistence += m.sum().item() + + def compute(self) -> dict[str, float]: + """Compute aggregate and per-horizon/per-variable metrics. + + Returns: + Dictionary with "loss" (mean squared error, for early stopping + and logging), "mse", "mae", "rmse", "skill" (1 - model_mse / + persistence_mse, NaN if no persistence baseline was given), and + per-horizon/per-variable "rmse_{var}_{lead}h" / "mae_{var}_{lead}h". + """ + total_mask = self._sum_mask.sum().item() + denom = max(total_mask, 1.0) + + mse = (self._sum_sq.sum().item()) / denom + mae = (self._sum_abs.sum().item()) / denom + rmse = mse ** 0.5 + + skill = float("nan") + if self._sum_mask_persistence > 0: + persistence_mse = self._sum_sq_persistence / self._sum_mask_persistence + if persistence_mse > 0: + skill = 1.0 - (mse / persistence_mse) + + result: dict[str, float] = { + "loss": mse, + "mse": mse, + "mae": mae, + "rmse": rmse, + "skill": skill, + } + + for i, lead_hr in enumerate(self.lead_times): + for j, var in enumerate(self.target_vars): + count = max(self._sum_mask[i, j].item(), 1.0) + var_mse = self._sum_sq[i, j].item() / count + result[f"rmse_{var}_{lead_hr}h"] = var_mse ** 0.5 + result[f"mae_{var}_{lead_hr}h"] = self._sum_abs[i, j].item() / count + + return result diff --git a/software/loaf/training/trainer.py b/software/loaf/training/trainer.py new file mode 100644 index 0000000..84ba54b --- /dev/null +++ b/software/loaf/training/trainer.py @@ -0,0 +1,336 @@ +"""Training loop for LOAF weather forecasting models. + +Supports both model backbones defined in loaf.model: the graph-based MPNN +and the attention-based VisionTransformer. Both are trained to predict +target_vars (default: u, v wind) at multiple forecast horizons in a single +forward pass, given a window of station (and optionally grid) history. +""" + +import csv +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import torch +from torch import nn +from torch.utils.data import DataLoader + +from loaf.model import MPNN, VisionTransformer +from loaf.training.evaluate import RunningMetrics + +MODEL_TYPES = ("mpnn", "vit") + + +def build_model( + model_cfg: dict[str, Any], + n_stations: int, + in_hrs: int, + n_station_vars: int, + n_out_features: int, + grid_vars: list[str] | None = None, + in_hrs_grid: int | None = None, +) -> nn.Module: + """Build an MPNN or VisionTransformer from a model config block. + + Args: + model_cfg: The "model" section of a LOAF YAML config (must include + "type": "mpnn" or "vit"). + n_stations: Number of stations (needed for ViT's positional embedding). + in_hrs: Number of historical hours in the station input window. + n_station_vars: Number of station input variables. + n_out_features: Total output width, i.e. n_lead_times * n_target_vars. + The caller is responsible for reshaping the model's flat output + back to (batch, n_stations, n_lead_times, n_target_vars). + grid_vars: Grid variables used, if grid fusion is enabled (MPNN only). + in_hrs_grid: Historical hours in the grid input window, if different + from in_hrs. + + Returns: + An uninitialized (freshly constructed) model. + """ + model_type = model_cfg.get("type", "mpnn").lower() + hidden_dim = model_cfg.get("hidden_dim", 128) + + if model_type == "mpnn": + n_grid_vars = len(grid_vars) if grid_vars else 1 + n_node_features_e = (in_hrs_grid or in_hrs) * n_grid_vars + return MPNN( + n_passing=model_cfg.get("num_gnn_layers", 2), + lead_hrs=0, + n_node_features_m=in_hrs * n_station_vars, + n_node_features_e=n_node_features_e, + n_out_features=n_out_features, + hidden_dim=hidden_dim, + ) + elif model_type == "vit": + return VisionTransformer( + n_stations=n_stations, + madis_len=in_hrs, + madis_n_vars_i=n_station_vars, + madis_n_vars_o=n_out_features, + dim=hidden_dim, + attn_dim=hidden_dim, + mlp_dim=hidden_dim * 2, + num_heads=model_cfg.get("num_heads", 3), + num_layers=model_cfg.get("num_transformer_layers", 5), + ) + else: + raise ValueError(f"Unknown model.type: {model_type!r} (expected one of {MODEL_TYPES})") + + +@dataclass +class TrainerState: + """Mutable training progress, returned by Trainer.fit().""" + + epoch: int = 0 + best_val_loss: float = float("inf") + best_epoch: int = -1 + epochs_without_improvement: int = 0 + history: list[dict[str, float]] = field(default_factory=list) + + +class Trainer: + """Trains an MPNN or VisionTransformer on a WeatherDataset. + + Args: + model: Model built by build_model(). + model_type: "mpnn" or "vit" - determines the forward-pass call signature. + train_loader: Training DataLoader (batches of WeatherDataset samples). + val_loader: Validation DataLoader. + output_dir: Directory for checkpoints (best.pt, last.pt) and train_log.csv. + lead_times: Forecast horizons in hours, matching the target's lead axis. + target_vars: Names of predicted variables, matching the target's var axis. + station_vars: Names of station input variables, in madis_x's var axis + order (used to build the persistence baseline for the skill metric). + target_stats: Per-variable {"min", "max"} used to denormalize predictions + before computing human-readable (physical-unit) metrics. + learning_rate: AdamW learning rate. + weight_decay: AdamW weight decay. + max_grad_norm: Gradient clipping norm (None to disable). + patience: Early-stopping patience in epochs (None/0 to disable). + device: Torch device string. Defaults to CUDA if available, else CPU. + """ + + def __init__( + self, + model: nn.Module, + model_type: str, + train_loader: DataLoader, + val_loader: DataLoader, + output_dir: str | Path, + lead_times: list[int], + target_vars: list[str], + station_vars: list[str], + target_stats: dict[str, dict[str, float]] | None = None, + learning_rate: float = 1e-4, + weight_decay: float = 1e-4, + max_grad_norm: float | None = 1.0, + patience: int | None = 10, + device: str | None = None, + ): + if model_type not in MODEL_TYPES: + raise ValueError(f"Unknown model_type: {model_type!r} (expected one of {MODEL_TYPES})") + + self.model_type = model_type + self.lead_times = lead_times + self.n_lead_times = len(lead_times) + self.target_vars = target_vars + self.n_target_vars = len(target_vars) + self.station_vars = station_vars + self.target_stats = target_stats or {} + + self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu")) + self.model = model.to(self.device) + + self.train_loader = train_loader + self.val_loader = val_loader + + self.optimizer = torch.optim.AdamW( + self.model.parameters(), lr=learning_rate, weight_decay=weight_decay + ) + self.max_grad_norm = max_grad_norm + self.patience = patience + + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self.log_path = self.output_dir / "train_log.csv" + + self.state = TrainerState() + + def _forward(self, batch: dict[str, torch.Tensor]) -> torch.Tensor: + """Run the model and reshape output to (batch, n_stations, n_lead_times, n_vars).""" + madis_x = batch["madis_x"].to(self.device) + ex_x = batch.get("ex_x") + + if self.model_type == "mpnn": + madis_lon = batch["madis_lon"].to(self.device) + madis_lat = batch["madis_lat"].to(self.device) + edge_index = batch["edge_index"].to(self.device) + + ex_lon = ex_lat = edge_index_e2m = None + if ex_x is not None: + ex_x = ex_x.to(self.device) + ex_lon = batch["ex_lon"].to(self.device) + ex_lat = batch["ex_lat"].to(self.device) + edge_index_e2m = batch["edge_index_e2m"].to(self.device) + + preds = self.model( + madis_x, madis_lon, madis_lat, edge_index, ex_lon, ex_lat, ex_x, edge_index_e2m + ) + else: # vit + if ex_x is not None: + ex_x = ex_x.to(self.device) + preds, _ = self.model(madis_x, era5_x=ex_x) + + n_batch, n_stations, _ = preds.shape + return preds.view(n_batch, n_stations, self.n_lead_times, self.n_target_vars) + + def _compute_loss(self, preds: torch.Tensor, batch: dict[str, torch.Tensor]) -> torch.Tensor: + """Masked MSE over real (non-imputed) target observations only.""" + target = batch["target"].to(self.device) + mask = batch["target_mask"].to(self.device) + + squared_error = (preds - target) ** 2 * mask + denom = mask.sum().clamp_min(1.0) + return squared_error.sum() / denom + + def _persistence_baseline(self, batch: dict[str, torch.Tensor]) -> torch.Tensor | None: + """Naive "no change from now" forecast, for the skill-score baseline.""" + try: + indices = [self.station_vars.index(var) for var in self.target_vars] + except ValueError: + return None + + madis_x = batch["madis_x"].to(self.device) + last_values = madis_x[:, :, -1, indices] # (batch, n_stations, n_target_vars) + return last_values.unsqueeze(2).expand(-1, -1, self.n_lead_times, -1) + + def _denormalize(self, values: torch.Tensor, var: str) -> torch.Tensor: + """Undo min-max normalization for human-readable metrics.""" + stats = self.target_stats.get(var) + if stats is None: + return values + eps = 1e-5 + return values * (stats["max"] - stats["min"] + eps) + stats["min"] + + def _denormalize_targets(self, values: torch.Tensor) -> torch.Tensor: + """Denormalize a (..., n_target_vars) tensor, one slice per variable.""" + if not self.target_stats: + return values + slices = [ + self._denormalize(values[..., i], var) for i, var in enumerate(self.target_vars) + ] + return torch.stack(slices, dim=-1) + + def train_epoch(self) -> float: + """Run one training epoch. Returns mean training loss.""" + self.model.train() + total_loss = 0.0 + n_batches = 0 + + for batch in self.train_loader: + self.optimizer.zero_grad() + preds = self._forward(batch) + loss = self._compute_loss(preds, batch) + loss.backward() + + if self.max_grad_norm: + nn.utils.clip_grad_norm_(self.model.parameters(), self.max_grad_norm) + + self.optimizer.step() + + total_loss += loss.item() + n_batches += 1 + + return total_loss / max(n_batches, 1) + + @torch.no_grad() + def validate_epoch(self) -> dict[str, float]: + """Run one validation epoch. Returns metrics from RunningMetrics.compute().""" + self.model.eval() + metrics = RunningMetrics(self.lead_times, self.target_vars) + + for batch in self.val_loader: + preds = self._forward(batch) + target = batch["target"].to(self.device) + mask = batch["target_mask"].to(self.device) + persistence = self._persistence_baseline(batch) + + denorm_persistence = ( + self._denormalize_targets(persistence) if persistence is not None else None + ) + metrics.update( + self._denormalize_targets(preds), + self._denormalize_targets(target), + mask, + persistence=denorm_persistence, + ) + + return metrics.compute() + + def fit(self, epochs: int) -> TrainerState: + """Train for up to `epochs` epochs, with checkpointing and early stopping.""" + self._init_log() + + for epoch in range(1, epochs + 1): + train_loss = self.train_epoch() + val_metrics = self.validate_epoch() + val_loss = val_metrics["loss"] + + print( + f"Epoch {epoch}/{epochs} - train_loss={train_loss:.4f} " + f"val_loss={val_loss:.4f} val_mae={val_metrics['mae']:.4f} " + f"val_rmse={val_metrics['rmse']:.4f} val_skill={val_metrics['skill']:.4f}" + ) + + self._log_epoch(epoch, train_loss, val_metrics) + self.state.epoch = epoch + self.state.history.append({"epoch": epoch, "train_loss": train_loss, **val_metrics}) + self._save_checkpoint("last.pt") + + if val_loss < self.state.best_val_loss: + self.state.best_val_loss = val_loss + self.state.best_epoch = epoch + self.state.epochs_without_improvement = 0 + self._save_checkpoint("best.pt") + else: + self.state.epochs_without_improvement += 1 + if self.patience and self.state.epochs_without_improvement >= self.patience: + print( + f"Early stopping at epoch {epoch} " + f"(no improvement for {self.patience} epochs)" + ) + break + + return self.state + + def _init_log(self) -> None: + if not self.log_path.exists(): + with open(self.log_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + ["epoch", "train_loss", "val_loss", "val_mae", "val_rmse", "val_skill"] + ) + + def _log_epoch(self, epoch: int, train_loss: float, val_metrics: dict[str, float]) -> None: + with open(self.log_path, "a", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [epoch, train_loss, val_metrics["loss"], val_metrics["mae"], + val_metrics["rmse"], val_metrics["skill"]] + ) + + def _save_checkpoint(self, filename: str) -> None: + torch.save( + { + "epoch": self.state.epoch, + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "model_type": self.model_type, + "lead_times": self.lead_times, + "target_vars": self.target_vars, + "station_vars": self.station_vars, + "target_stats": self.target_stats, + }, + self.output_dir / filename, + ) diff --git a/software/scripts/train.py b/software/scripts/train.py new file mode 100644 index 0000000..54b4cb3 --- /dev/null +++ b/software/scripts/train.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Training entry point for LOAF weather forecasting models. + +Trains an MPNN (graph) or VisionTransformer (attention) model to predict +wind (u, v) at multiple forecast horizons, from downloaded IEM station data +and (optionally) HRRR/ERA5 grid data. See software/config/*.yaml for region +configs. + +Usage (from the repo root): + python software/scripts/train.py --config software/config/arlington.yaml + python software/scripts/train.py --config software/config/seattle.yaml \\ + --model-type vit --epochs 20 +""" + +import argparse +import logging +import random +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path + +import numpy as np +import torch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from loaf.config import load_config # noqa: E402 +from loaf.data.loaders import create_dataloaders # noqa: E402 +from loaf.training import Trainer, build_model # noqa: E402 + +logger = logging.getLogger(__name__) + + +def set_seed(seed: int) -> None: + """Seed Python, NumPy, and Torch RNGs for reproducible runs.""" + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def infer_year(iem_dir: Path) -> int: + """Pick the year with the most downloaded IEM files, when --year isn't given.""" + files = list(iem_dir.glob("iem_*.parquet")) + list(iem_dir.glob("iem_*.csv")) + if not files: + raise FileNotFoundError( + f"No IEM data found in {iem_dir} - download data first (see " + f"loaf-download-iem) or pass --year explicitly." + ) + + years = Counter(int(f.stem.split("_")[1]) for f in files) + return years.most_common(1)[0][0] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Train a LOAF weather forecasting model (MPNN or ViT)." + ) + parser.add_argument( + "--config", + "-c", + required=True, + help="Path to a LOAF YAML config file (e.g. software/config/arlington.yaml).", + ) + parser.add_argument( + "--data-dir", + default="data", + help="Base directory containing downloaded data (default: data)", + ) + parser.add_argument( + "--output-dir", + default=None, + help="Directory for checkpoints/logs (default: runs/_)", + ) + parser.add_argument( + "--year", + type=int, + default=None, + help="Year of data to train on (default: year with the most downloaded IEM data)", + ) + parser.add_argument( + "--model-type", choices=["mpnn", "vit"], default=None, help="Override config model.type" + ) + parser.add_argument( + "--epochs", type=int, default=None, help="Override config training.epochs" + ) + parser.add_argument( + "--batch-size", type=int, default=None, help="Override config training.batch_size" + ) + parser.add_argument( + "--learning-rate", type=float, default=None, help="Override config training.learning_rate" + ) + parser.add_argument( + "--min-observations", + type=int, + default=24, + help="Minimum real observations required to keep a station (default: 24)", + ) + parser.add_argument( + "--use-hrrr", + action="store_true", + help="Fuse HRRR grid data (requires hourly HRRR coverage over the training window)", + ) + parser.add_argument( + "--use-era5", + action="store_true", + help="Fuse ERA5 grid data (requires hourly ERA5 coverage over the training window)", + ) + parser.add_argument( + "--num-workers", type=int, default=0, help="DataLoader worker processes (default: 0)" + ) + parser.add_argument( + "--device", default=None, help="Device to train on (default: cuda if available, else cpu)" + ) + + args = parser.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + config = load_config(args.config) + region_cfg = config.get("region", {}) + data_cfg = config.get("data", {}) + model_cfg = dict(config.get("model", {})) + training_cfg = config.get("training", {}) + + if args.model_type: + model_cfg["type"] = args.model_type + model_cfg.setdefault("type", "mpnn") + + epochs = args.epochs or training_cfg.get("epochs", 100) + batch_size = args.batch_size or training_cfg.get("batch_size", 32) + learning_rate = args.learning_rate or training_cfg.get("learning_rate", 1e-4) + weight_decay = training_cfg.get("weight_decay", 1e-4) + val_split = training_cfg.get("val_split", 0.15) + patience = training_cfg.get("patience", 10) + max_grad_norm = training_cfg.get("max_grad_norm", 1.0) + seed = training_cfg.get("seed", 42) + + set_seed(seed) + + back_hrs = data_cfg.get("back_hrs", 24) + lead_times = data_cfg.get("lead_times") or [data_cfg.get("lead_hrs", 48)] + + lat_bounds = (region_cfg.get("lat_min"), region_cfg.get("lat_max")) + lon_bounds = (region_cfg.get("lon_min"), region_cfg.get("lon_max")) + + data_dir = Path(args.data_dir) + year = args.year or infer_year(data_dir / "iem") + + region_name = region_cfg.get("name", "run") + logger.info( + f"Training {model_cfg['type']} model for region '{region_name}', year {year}, " + f"lead_times={lead_times}h" + ) + + bundle = create_dataloaders( + data_dir=data_dir, + year=year, + back_hrs=back_hrs, + lead_times=lead_times, + batch_size=batch_size, + val_split=val_split, + num_workers=args.num_workers, + lat_bounds=lat_bounds, + lon_bounds=lon_bounds, + use_era5=args.use_era5, + use_hrrr=args.use_hrrr, + min_observations=args.min_observations, + ) + + dataset = bundle.dataset + logger.info( + f"Loaded {len(dataset)} samples, {dataset.n_stations} stations " + f"({len(bundle.train_loader.dataset)} train / {len(bundle.val_loader.dataset)} val)" + ) + + model = build_model( + model_cfg, + n_stations=dataset.n_stations, + in_hrs=back_hrs, + n_station_vars=len(dataset.station_vars), + n_out_features=len(lead_times) * len(dataset.target_vars), + grid_vars=dataset.grid_vars if dataset.grid_loader is not None else None, + ) + n_params = sum(p.numel() for p in model.parameters()) + logger.info(f"Built {model_cfg['type']} model with {n_params:,} parameters") + + output_dir = ( + Path(args.output_dir) + if args.output_dir + else Path("runs") / f"{region_name}_{datetime.now():%Y%m%d_%H%M%S}" + ) + + target_stats = { + var: dataset.station_stats[var] + for var in dataset.target_vars + if var in dataset.station_stats + } + + trainer = Trainer( + model=model, + model_type=model_cfg["type"], + train_loader=bundle.train_loader, + val_loader=bundle.val_loader, + output_dir=output_dir, + lead_times=dataset.lead_times, + target_vars=dataset.target_vars, + station_vars=dataset.station_vars, + target_stats=target_stats, + learning_rate=learning_rate, + weight_decay=weight_decay, + max_grad_norm=max_grad_norm, + patience=patience, + device=args.device, + ) + + state = trainer.fit(epochs) + + logger.info( + f"Training complete. Best val loss {state.best_val_loss:.4f} at epoch " + f"{state.best_epoch}. Checkpoints saved to {output_dir}" + ) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 936e804..53a8c41 100644 --- a/uv.lock +++ b/uv.lock @@ -664,7 +664,7 @@ name = "cuda-bindings" version = "12.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/7a/d8/b546104b8da3f562c1ff8ab36d130c8fe1dd6a045ced80b4f6ad74f7d4e1/cuda_bindings-12.9.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d3c842c2a4303b2a580fe955018e31aea30278be19795ae05226235268032e5", size = 12148218, upload-time = "2025-10-21T14:51:28.855Z" }, @@ -709,8 +709,8 @@ name = "eccodeslib" version = "2.45.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eckitlib", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" }, - { name = "fckitlib", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" }, + { name = "eckitlib" }, + { name = "fckitlib" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d6/96/f11d966599b653342c53d06f3d0693f907e75ecbb524ebbd8f605f1cebc5/eccodeslib-2.45.1.9-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:eb069c7226cc2ecad7a4674d21f631cde9f0fb9a24c9f843772e24c1eaf9ed73", size = 8968725, upload-time = "2026-01-26T16:53:13.986Z" }, @@ -782,7 +782,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -794,7 +794,7 @@ name = "fckitlib" version = "0.14.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eckitlib", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" }, + { name = "eckitlib" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/3c/1b/b36e2a11c08f849084534a81bc826f739ad4d87095a8da63625c6aeabcdd/fckitlib-0.14.1.9-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a8a622dca3c6047f75fcdcd61b051d450c9f1d7aa40df7c6a54b5b9a997ebd21", size = 411475, upload-time = "2026-01-26T16:53:31.247Z" }, @@ -1029,14 +1029,14 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "cfgrib", marker = "python_full_version < '3.11'" }, - { name = "eccodes", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "toml", marker = "python_full_version < '3.11'" }, - { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cfgrib" }, + { name = "eccodes" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, + { name = "pyproj", version = "3.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "requests" }, + { name = "toml" }, + { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/a8e888869b444ae506f93bb9ea8cf69b88fa968320583543e804197a56f0/herbie_data-2025.12.0.tar.gz", hash = "sha256:e95d9803c439f1dda916b3705d20bf7ef1e71141dbeeb59c43506522ecb3616f", size = 9422366, upload-time = "2025-12-05T23:15:16.336Z" } wheels = [ @@ -1062,14 +1062,14 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "cfgrib", marker = "python_full_version >= '3.11'" }, - { name = "eccodes", marker = "python_full_version >= '3.11'" }, - { name = "eccodeslib", marker = "python_full_version >= '3.11' and sys_platform != 'win32'" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "xarray", version = "2026.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cfgrib" }, + { name = "eccodes" }, + { name = "eccodeslib", marker = "sys_platform != 'win32'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" } }, + { name = "requests" }, + { name = "xarray", version = "2026.1.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/f6/b5b6c2696dbdb4d5310538e48bf1331976477ded3951bf235073574f692d/herbie_data-2026.1.0.tar.gz", hash = "sha256:96ab0e2325dc43234f7425777fe8af960ac54d8bec4acc8ffbb7fafd13691181", size = 10808960, upload-time = "2026-01-27T20:45:50.707Z" } wheels = [ @@ -1215,6 +1215,7 @@ dependencies = [ { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyarrow" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, @@ -1257,6 +1258,7 @@ requires-dist = [ { name = "networkx", specifier = ">=3.0" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "pandas", specifier = ">=2.0.0" }, + { name = "pyarrow", specifier = ">=14.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.0.0" }, @@ -1747,9 +1749,9 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" }, - { name = "cftime", marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'ARM64' and sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "cftime" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/76/7bc801796dee752c1ce9cd6935564a6ee79d5c9d9ef9192f57b156495a35/netcdf4-1.7.3.tar.gz", hash = "sha256:83f122fc3415e92b1d4904fd6a0898468b5404c09432c34beb6b16c533884673", size = 836095, upload-time = "2025-10-13T18:38:00.76Z" } @@ -1773,8 +1775,8 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.11' or platform_machine != 'ARM64' or sys_platform != 'win32'" }, - { name = "cftime", marker = "python_full_version >= '3.11' or platform_machine != 'ARM64' or sys_platform != 'win32'" }, + { name = "certifi" }, + { name = "cftime" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')" }, { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] @@ -2035,7 +2037,7 @@ name = "nvidia-cudnn-cu12" version = "9.10.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, @@ -2046,7 +2048,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, @@ -2073,9 +2075,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-cusparse-cu12", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-nvjitlink-cu12", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, @@ -2086,7 +2088,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "(python_full_version < '3.11' and platform_machine != 'ARM64' and sys_platform == 'win32') or (python_full_version < '3.11' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, @@ -2159,10 +2161,10 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2234,9 +2236,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } wheels = [ @@ -2458,6 +2460,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" }, + { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" }, + { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" }, + { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" }, + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2507,7 +2566,7 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.11'" }, + { name = "certifi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339, upload-time = "2025-02-16T04:28:46.621Z" } wheels = [ @@ -2564,7 +2623,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.11'" }, + { name = "certifi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } wheels = [ @@ -2863,10 +2922,10 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -2921,10 +2980,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, + { name = "joblib" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -2975,7 +3034,7 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -3045,7 +3104,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ @@ -3254,6 +3313,17 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/bfebdd8ec77db9a79775121789992d6b3b75ee5494971294d7b4b7c999bc/torch-2.10.0-2-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:2b980edd8d7c0a68c4e951ee1856334a43193f98730d97408fbd148c1a933313", size = 79411457, upload-time = "2026-02-10T21:44:59.189Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/efbd56687be60ef9af0c9c0ebe106964c07400eade5b0af8902a1d8cd58c/torch-2.10.0-3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a1ff626b884f8c4e897c4c33782bdacdff842a165fee79817b1dd549fdda1321", size = 915510070, upload-time = "2026-03-11T14:16:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" }, { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" }, { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" }, @@ -3411,9 +3481,9 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'ARM64') or (python_full_version < '3.11' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } wheels = [ @@ -3439,9 +3509,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/85/113ff1e2cde9e8a5b13c2f0ef4e9f5cd6ca3a036b6452f4dd523419289b5/xarray-2026.1.0.tar.gz", hash = "sha256:0c9814761f9d9a9545df37292d3fda89f83201f3e02ae0f09f03313d9cfdd5e2", size = 3107024, upload-time = "2026-01-28T17:49:03.822Z" } wheels = [