Kaynağa Gözat

First pass on cleanup

Nicholas Schense 2 hafta önce
ebeveyn
işleme
9a2c1b5cbb
8 değiştirilmiş dosya ile 249 ekleme ve 81 silme
  1. 10 0
      config.toml
  2. 71 28
      model/dataset.py
  3. 2 0
      pyproject.toml
  4. 65 44
      tasks/__init__.py
  5. 21 6
      tasks/load_data.py
  6. 12 1
      tasks/train_bayesian.py
  7. 13 1
      tasks/train_normal.py
  8. 55 1
      uv.lock

+ 10 - 0
config.toml

@@ -21,3 +21,13 @@ ensemble_size = 30
 droprate = 0.05
 learning_rate = 0.0001
 num_epochs = 25
+deterministic = false # force deterministic cuDNN kernels (slower, exact reproduction)
+
+
+[evaluation]
+# Gaussian noise standard deviations applied to images during noisy evaluation
+# (step 6). 0.0 is the clean baseline and should be kept first.
+noise_levels = [0.0, 0.02, 0.05, 0.1, 0.2]
+# Monte-Carlo forward passes used to estimate Bayesian predictive/model
+# uncertainty (step 5/6). Ignored for the deterministic ensemble.
+mc_passes = 30

+ 71 - 28
model/dataset.py

@@ -1,3 +1,4 @@
+import math
 import pathlib as pl
 import random
 import re
@@ -10,6 +11,15 @@ import torch.utils.data as data
 from jaxtyping import Float
 from torch.utils.data import DataLoader, Subset
 
+from util.seeding import torch_generator
+
+# Tokens that appear in MRI filenames to indicate the diagnostic class.
+# On disk the cognitively-normal class is spelled "NL" (not "CN").
+CLASS_TOKENS: Dict[str, torch.Tensor] = {
+    "AD": torch.tensor([1.0, 0.0]),
+    "NL": torch.tensor([0.0, 1.0]),
+}
+
 
 def _row_to_float_tensor(row: pd.DataFrame, *, image_id: int) -> torch.Tensor:
     values = row.drop(columns=["Image Data ID"]).iloc[0]
@@ -28,12 +38,18 @@ def xls_pre(df: pd.DataFrame) -> pd.DataFrame:
     """
     Preprocess the Excel DataFrame.
     This function can be customized to filter or modify the DataFrame as needed.
+
+    Returns a DataFrame with columns [Image Data ID, Sex, Age (current)] where
+    Sex is encoded 0/1. "Image Data ID" is deliberately kept as a column (not an
+    index) because downstream lookups filter on it directly
+    (``xls_values["Image Data ID"] == img_id``).
     """
 
-    data: pd.DataFrame = df[["Image Data ID", "Sex", "Age (current)"]]  # pyright: ignore
-    data["Sex"] = data["Sex"].str.strip()  # type: ignore
-    data = data.replace({"M": 0, "F": 1})  # type: ignore
-    data.set_index("Image Data ID")  # type: ignore
+    # Work on an explicit copy so column assignment writes back reliably instead
+    # of raising SettingWithCopyWarning against a view of ``df``.
+    data = df[["Image Data ID", "Sex", "Age (current)"]].copy()
+    data["Sex"] = data["Sex"].str.strip()
+    data = data.replace({"M": 0, "F": 1})
 
     return data
 
@@ -136,16 +152,18 @@ def load_adni_data_from_file(
 
         file_mri_data = torch.from_numpy(nib.load(file).get_fdata())  # type: ignore # type checking does not work well with nibabel
 
-        # Read the filename to determine the expected class
-        file_expected_class = torch.tensor([0.0, 0.0])  # Default to a tensor of zeros
+        # Read the filename to determine the expected class. On disk the classes
+        # are spelled "AD" and "NL"; see CLASS_TOKENS.
+        file_expected_class: torch.Tensor | None = None
+        for token, one_hot in CLASS_TOKENS.items():
+            if token in filename:
+                file_expected_class = one_hot.clone()
+                break
 
-        if "AD" in filename:
-            file_expected_class = torch.tensor([1.0, 0.0])
-        elif "NL" in filename:
-            file_expected_class = torch.tensor([0.0, 1.0])
-        else:
+        if file_expected_class is None:
             raise ValueError(
-                f"Filename {filename} does not contain a valid class identifier (AD or CN)."
+                f"Filename {filename} does not contain a valid class identifier "
+                f"({' or '.join(CLASS_TOKENS)})."
             )
 
         mri_data_unstacked.append(file_mri_data)
@@ -196,8 +214,8 @@ def divide_dataset(
     Returns:
         Result[List[data.Subset[ADNIDataset]], str]: A Result object containing the subsets or an error message.
     """
-    if sum(ratios) != 1.0:
-        raise ValueError(f"Ratios must sum to 1.0, got {ratios}.")
+    if not math.isclose(sum(ratios), 1.0, rel_tol=1e-9, abs_tol=1e-9):
+        raise ValueError(f"Ratios must sum to 1.0, got {ratios} (sum={sum(ratios)}).")
 
     # Set the random seed for reproducibility
     gen = torch.Generator().manual_seed(seed)
@@ -207,27 +225,52 @@ def divide_dataset(
 def initalize_dataloaders(
     datasets: List[Subset[ADNIDataset]],
     batch_size: int = 64,
+    seed: int | None = None,
 ) -> List[DataLoader[ADNIDataset]]:
     """
-    Initializes the DataLoader for the given datasets.
+    Initializes the DataLoaders for the [train, val, test] datasets.
+
+    The three splits are configured differently:
+      - train: shuffled (for SGD) with a seeded generator for reproducibility,
+        and ``drop_last=True`` so a size-1 final batch cannot crash BatchNorm1d.
+      - val / test: NOT shuffled, so sample order is stable. Stable order is
+        required for evaluation, where saved model outputs must align with a
+        fixed sample axis (see the netCDF evaluation schema).
 
     Args:
-        datasets (List[Subset[ADNIDataset]]): List of datasets to create DataLoaders for.
-        batch_size (int): The batch size for the DataLoader.
+        datasets: Subsets in ``[train, val, test]`` order.
+        batch_size: The batch size for the DataLoaders.
+        seed: Seed for the training shuffle generator. ``None`` leaves shuffling
+            to global RNG state.
 
     Returns:
-        List[DataLoader[ADNIDataset]]: A list of DataLoaders for the datasets.
+        List[DataLoader[ADNIDataset]]: DataLoaders in the same order as ``datasets``.
     """
+    loader_configs = [
+        {"shuffle": True, "drop_last": True},  # train
+        {"shuffle": False, "drop_last": False},  # val
+        {"shuffle": False, "drop_last": False},  # test
+    ]
+    if len(datasets) != len(loader_configs):
+        raise ValueError(
+            f"Expected 3 datasets ([train, val, test]), got {len(datasets)}."
+        )
+
     pin_memory = torch.cuda.is_available()
-    return [
-        DataLoader(
-            dataset,
-            batch_size=batch_size,
-            shuffle=True,
-            pin_memory=pin_memory,
+    loaders: List[DataLoader[ADNIDataset]] = []
+    for dataset, cfg in zip(datasets, loader_configs):
+        generator = torch_generator(seed) if cfg["shuffle"] else None
+        loaders.append(
+            DataLoader(
+                dataset,
+                batch_size=batch_size,
+                shuffle=cfg["shuffle"],
+                drop_last=cfg["drop_last"],
+                pin_memory=pin_memory,
+                generator=generator,
+            )
         )
-        for dataset in datasets
-    ]
+    return loaders
 
 
 def divide_dataset_by_patient_id(
@@ -253,8 +296,8 @@ def divide_dataset_by_patient_id(
         This split is grouped by PTID, so all images from the same patient are assigned
         to exactly one partition to avoid patient-level leakage across train/val/test.
     """
-    if sum(ratios) != 1.0:
-        raise ValueError(f"Ratios must sum to 1.0, got {ratios}.")
+    if not math.isclose(sum(ratios), 1.0, rel_tol=1e-9, abs_tol=1e-9):
+        raise ValueError(f"Ratios must sum to 1.0, got {ratios} (sum={sum(ratios)}).")
 
     if not ptids:
         raise ValueError("ptids list cannot be empty.")

+ 2 - 0
pyproject.toml

@@ -21,6 +21,8 @@ dependencies = [
     "jaxtyping",
     "textual",
     "toml",
+    "xarray",
+    "netcdf4>=1.7.4",
 ]
 
 

+ 65 - 44
tasks/__init__.py

@@ -1,4 +1,21 @@
-import time
+"""Pipeline task registry and scenario definitions.
+
+A *task* is a callable ``task(tracker, logger, config, state) -> None``. A
+*scenario* is an ordered list of task ids the pipeline runs. Tasks communicate
+through the mutable ``state`` dict (dataloaders, model paths, control events).
+
+Pipeline stages (see ai/ARCHITECTURE.md):
+    1. load_data        -> implemented
+    2. train_regular    -> implemented
+    3. train_bayesian   -> implemented
+    4. evaluate_regular -> PLANNED (placeholder)
+    5. evaluate_bayesian-> PLANNED (placeholder)
+    6. evaluate_noisy   -> PLANNED (placeholder)
+    load_models         -> PLANNED (placeholder); loads saved .pt ensembles from
+                           disk into ``state`` so evaluation is decoupled from
+                           training (training deletes models from VRAM after saving).
+"""
+
 from typing import Any, Dict
 
 from util.progress import ProgressTracker
@@ -9,48 +26,23 @@ from . import train_bayesian
 from . import train_normal
 
 
-def dummy_task(
-    tracker: ProgressTracker,
-    logger: PipelineLogger,
-    config: Dict[str, Any],
-    state: Dict[str, Any],
-):
-    stop_event = state["events"]["stop"]
-    pause_event = state["events"]["pause"]
-
-    steps = 5
-    # The title is now set gracefully by the parent injecting the sub_tracker,
-    # so we just initialize the total.
-    tracker.update(total=steps, advance=0)
-
-    logger.info("Initializing process...")
+def _placeholder_task(planned: str):
+    """Build a no-op task for a pipeline stage that is not implemented yet.
 
-    for i in range(steps):
-        if stop_event.is_set():
-            raise InterruptedError("Pipeline execution stopped by user.")
+    It logs a clear warning and returns cleanly (advancing the pipeline) instead
+    of crashing, so the scenario wiring can be exercised before the real
+    evaluation logic lands.
+    """
 
-        while pause_event.is_set():
-            time.sleep(0.5)
-            if stop_event.is_set():
-                raise InterruptedError(
-                    "Pipeline execution stopped by user while paused."
-                )
+    def _task(
+        tracker: ProgressTracker,
+        logger: PipelineLogger,
+        config: Dict[str, Any],
+        state: Dict[str, Any],
+    ) -> None:
+        logger.info(f"[NOT IMPLEMENTED] {planned} — skipping (placeholder).")
 
-        # Child Process (Sub Progress Tracker)
-        sub_steps = 10
-        sub_tracker = tracker.get_sub_tracker(f"Batch {i + 1}")
-        sub_tracker.update(total=sub_steps, advance=0)
-
-        for j in range(sub_steps):
-            if stop_event.is_set():
-                raise InterruptedError("Pipeline execution stopped by user.")
-            time.sleep(0.05)
-            sub_tracker.update(advance=1)  # Advance sub task
-
-        tracker.update(advance=1)  # Advance main task (clears sub task)
-
-        if i == 2:
-            logger.info("Halfway through current task execution...")
+    return _task
 
 
 PIPELINE_TASKS = {
@@ -66,13 +58,29 @@ PIPELINE_TASKS = {
         "task_name": "Train Bayesian Models",
         "task_func": train_bayesian.train_bayesian_task,
     },
+    "load_models": {
+        "task_name": "Load Saved Models",
+        "task_func": _placeholder_task(
+            "Load trained normal/Bayesian ensembles from work_dir into state"
+        ),
+    },
     "evaluate_regular": {
         "task_name": "Evaluate Regular Models",
-        "task_func": dummy_task,
+        "task_func": _placeholder_task(
+            "Evaluate normal ensemble and save netCDF (step 4)"
+        ),
     },
     "evaluate_bayesian": {
         "task_name": "Evaluate Bayesian Models",
-        "task_func": dummy_task,
+        "task_func": _placeholder_task(
+            "Evaluate Bayesian ensemble with MC uncertainty and save netCDF (step 5)"
+        ),
+    },
+    "evaluate_noisy": {
+        "task_name": "Evaluate Models on Noised Data",
+        "task_func": _placeholder_task(
+            "Evaluate both ensembles across Gaussian noise levels and save netCDF (step 6)"
+        ),
     },
 }
 
@@ -83,16 +91,29 @@ SCENARIOS = {
             "load_data",
             "train_regular",
             "train_bayesian",
+            "load_models",
             "evaluate_regular",
             "evaluate_bayesian",
+            "evaluate_noisy",
         ],
     },
     "scen_load_all": {
         "label": "2. Load, Evaluate, & Noise Analysis",
-        "tasks": ["load_data", "evaluate_regular", "evaluate_bayesian"],
+        "tasks": [
+            "load_data",
+            "load_models",
+            "evaluate_regular",
+            "evaluate_bayesian",
+            "evaluate_noisy",
+        ],
     },
     "scen_load_eval": {
         "label": "3. Load & Evaluate (Skip Noise)",
-        "tasks": ["load_data", "evaluate_regular", "evaluate_bayesian"],
+        "tasks": [
+            "load_data",
+            "load_models",
+            "evaluate_regular",
+            "evaluate_bayesian",
+        ],
     },
 }

+ 21 - 6
tasks/load_data.py

@@ -5,6 +5,7 @@ import pandas as pd
 
 import model.dataset as ds
 from util.progress import ProgressTracker
+from util.seeding import seed_everything
 from util.ui_logger import PipelineLogger
 
 
@@ -15,6 +16,21 @@ def load_data_task(
     state: Dict[str, Any],
 ):
 
+    if config["data"]["seed"] is None:
+        log.info("Seed is not defined - using default seed of 0")
+        config["data"]["seed"] = 0
+
+    # Seed everything once at the start of the pipeline so data loading, weight
+    # init, and shuffling are reproducible. Per-member seeds are derived from
+    # this base inside the training tasks.
+    base_seed = int(config["data"]["seed"])
+    deterministic = bool(config.get("training", {}).get("deterministic", False))
+    applied = seed_everything(base_seed, deterministic=deterministic)
+    log.info(
+        f"Seeded RNGs with base seed {applied}"
+        + (" (deterministic cuDNN)" if deterministic else "")
+    )
+
     log.info("Loading Files")
     mri_files = pl.Path(config["data"]["mri_files_path"]).glob("*.nii")
     xls_file = pl.Path(config["data"]["xls_file_path"])
@@ -26,10 +42,6 @@ def load_data_task(
         xls_preprocessor=ds.xls_pre,
     )
 
-    if config["data"]["seed"] is None:
-        log.info("Seed is not defined - using default seed of 0")
-        config["data"]["seed"] = 0
-
     ptid_df = pd.read_csv(xls_file)
     ptid_df.columns = ptid_df.columns.str.strip()
 
@@ -50,9 +62,12 @@ def load_data_task(
         seed=config["data"]["seed"],
     )
 
-    # Initialize the dataloaders
+    # Initialize the dataloaders. The train shuffle generator is seeded from the
+    # base seed so shuffling is reproducible; val/test are left unshuffled.
     train_loader, val_loader, test_loader = ds.initalize_dataloaders(
-        datasets, batch_size=config["training"]["batch_size"]
+        datasets,
+        batch_size=config["training"]["batch_size"],
+        seed=base_seed,
     )
 
     log.info("Dataloaders initalized")

+ 12 - 1
tasks/train_bayesian.py

@@ -13,8 +13,13 @@ import model.training as tn
 from model.cnn import CNN3D
 from model.dnn_mod import dnn_to_bnn_mod, get_kl_loss
 from util.progress import ProgressTracker
+from util.seeding import derive_seed, seed_everything
 from util.ui_logger import PipelineLogger
 
+# RNG stream id for the Bayesian ensemble; distinct from the normal ensemble so
+# bayesian-member-N and normal-member-N do not share an RNG stream.
+_BAYESIAN_SEED_STREAM = 1
+
 
 def _release_torch_memory(device: str) -> None:
     gc.collect()
@@ -73,10 +78,16 @@ def train_bayesian_task(
         "moped_delta": 0.5,
     }
 
+    base_seed = int(config["data"]["seed"])
+
     for model_num in range(config["training"]["ensemble_size"]):
         _check_control_events(stop_event=stop_event, pause_event=pause_event)
+        # Seed per member (distinct stream from the normal ensemble).
+        member_seed = derive_seed(base_seed, model_num, stream=_BAYESIAN_SEED_STREAM)
+        seed_everything(member_seed)
         log.info(
-            f"Training model {model_num + 1}/{config['training']['ensemble_size']}..."
+            f"Training model {model_num + 1}/{config['training']['ensemble_size']} "
+            f"(seed {member_seed})..."
         )
         model = CNN3D(
             image_channels=config["data"]["image_channels"],

+ 13 - 1
tasks/train_normal.py

@@ -12,8 +12,13 @@ import model.dataset as ds
 import model.training as tn
 from model.cnn import CNN3D
 from util.progress import ProgressTracker
+from util.seeding import derive_seed, seed_everything
 from util.ui_logger import PipelineLogger
 
+# RNG stream id for the normal ensemble, keeps its seeds distinct from the
+# Bayesian ensemble's (see util.seeding.derive_seed).
+_NORMAL_SEED_STREAM = 0
+
 
 def _release_torch_memory(device: str) -> None:
     gc.collect()
@@ -59,12 +64,19 @@ def train_normal_task(
         intermediate_model_dir.mkdir(parents=True, exist_ok=True)
     log.info(f"Intermediate models will be saved to {intermediate_model_dir}")
 
+    base_seed = int(config["data"]["seed"])
+
     train_progress = track.get_sub_tracker("Training Progress")
     track.update(total=config["training"]["ensemble_size"], advance=0)
     for model_num in range(config["training"]["ensemble_size"]):
         _check_control_events(stop_event=stop_event, pause_event=pause_event)
+        # Seed per member so weight init / dropout are reproducible AND distinct
+        # across ensemble members.
+        member_seed = derive_seed(base_seed, model_num, stream=_NORMAL_SEED_STREAM)
+        seed_everything(member_seed)
         log.info(
-            f"Training model {model_num + 1}/{config['training']['ensemble_size']}..."
+            f"Training model {model_num + 1}/{config['training']['ensemble_size']} "
+            f"(seed {member_seed})..."
         )
         # Train the model
         model = (

+ 55 - 1
uv.lock

@@ -2,7 +2,8 @@ version = 1
 revision = 3
 requires-python = ">=3.14"
 resolution-markers = [
-    "sys_platform == 'win32'",
+    "platform_machine == 'ARM64' and sys_platform == 'win32'",
+    "platform_machine != 'ARM64' and sys_platform == 'win32'",
     "sys_platform == 'emscripten'",
     "sys_platform != 'emscripten' and sys_platform != 'win32'",
 ]
@@ -25,6 +26,7 @@ dependencies = [
     { name = "jaxtyping" },
     { name = "jupyterlab" },
     { name = "matplotlib" },
+    { name = "netcdf4" },
     { name = "nibabel" },
     { name = "numpy" },
     { name = "pandas" },
@@ -45,6 +47,7 @@ requires-dist = [
     { name = "jaxtyping" },
     { name = "jupyterlab" },
     { name = "matplotlib" },
+    { name = "netcdf4", specifier = ">=1.7.4" },
     { name = "nibabel" },
     { name = "numpy" },
     { name = "pandas" },
@@ -260,6 +263,30 @@ wheels = [
 ]
 
 [[package]]
+name = "cftime"
+version = "1.6.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/dc/470ffebac2eb8c54151eb893055024fe81b1606e7c6ff8449a588e9cd17f/cftime-1.6.5.tar.gz", hash = "sha256:8225fed6b9b43fb87683ebab52130450fc1730011150d3092096a90e54d1e81e", size = 326605, upload-time = "2025-10-13T18:56:26.352Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ea/6c/a9618f589688358e279720f5c0fe67ef0077fba07334ce26895403ebc260/cftime-1.6.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c69ce3bdae6a322cbb44e9ebc20770d47748002fb9d68846a1e934f1bd5daf0b", size = 502725, upload-time = "2025-10-13T18:56:19.424Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/e3/da3c36398bfb730b96248d006cabaceed87e401ff56edafb2a978293e228/cftime-1.6.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e62e9f2943e014c5ef583245bf2e878398af131c97e64f8cd47c1d7baef5c4e2", size = 485445, upload-time = "2025-10-13T18:56:20.853Z" },
+    { url = "https://files.pythonhosted.org/packages/32/93/b05939e5abd14bd1ab69538bbe374b4ee2a15467b189ff895e9a8cdaddf6/cftime-1.6.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da5fdaa4360d8cb89b71b8ded9314f2246aa34581e8105c94ad58d6102d9e4f", size = 1584434, upload-time = "2025-10-13T19:39:17.084Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/89/648397f9936e0b330999c4e776ebf296ec3c6a65f9901687dbca4ab820da/cftime-1.6.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bff865b4ea4304f2744a1ad2b8149b8328b321dd7a2b9746ef926d229bd7cd49", size = 1609812, upload-time = "2025-10-13T18:56:21.971Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/0f/901b4835aa67ad3e915605d4e01d0af80a44b114eefab74ae33de6d36933/cftime-1.6.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e552c5d1c8a58f25af7521e49237db7ca52ed2953e974fe9f7c4491e95fdd36c", size = 1669768, upload-time = "2025-10-13T18:56:24.027Z" },
+    { url = "https://files.pythonhosted.org/packages/22/d5/e605e4b28363e7a9ae98ed12cabbda5b155b6009270e6a231d8f10182a17/cftime-1.6.5-cp314-cp314-win_amd64.whl", hash = "sha256:e645b095dc50a38ac454b7e7f0742f639e7d7f6b108ad329358544a6ff8c9ba2", size = 463818, upload-time = "2025-10-13T18:56:25.376Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/89/a8f85ae697ff10206ec401c2621f5ca9f327554f586d62f244739ceeb347/cftime-1.6.5-cp314-cp314-win_arm64.whl", hash = "sha256:b9044d7ac82d3d8af189df1032fdc871bbd3f3dd41a6ec79edceb5029b71e6e0", size = 459862, upload-time = "2026-01-02T20:45:02.625Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/05/7410e12fd03a0c52717e74e6a1b49958810807dda212e23b65d43ea99676/cftime-1.6.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ef56460cb0576e1a9161e1428c9e1a633f809a23fa9d598f313748c1ae5064e", size = 533781, upload-time = "2026-01-02T20:45:04.818Z" },
+    { url = "https://files.pythonhosted.org/packages/44/ba/10e3546426d3ed9f9cc82e4a99836bb6fac1642c7830f7bdd0ac1c3f0805/cftime-1.6.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f4873d38b10032f9f3111c547a1d485519ae64eee6a7a2d091f1f8b08e1ba50", size = 515218, upload-time = "2026-01-02T20:45:06.788Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/68/efa11eae867749e921bfec6a865afdba8166e96188112dde70bb8bb49254/cftime-1.6.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccce0f4c9d3f38dd948a117e578b50d0e0db11e2ca9435fb358fd524813e4b61", size = 1579932, upload-time = "2026-01-02T20:45:11.194Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/6c/0971e602c1390a423e6621dfbad9f1d375186bdaf9c9c7f75e06f1fbf355/cftime-1.6.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19cbfc5152fb0b34ce03acf9668229af388d7baa63a78f936239cb011ccbe6b1", size = 1555894, upload-time = "2026-01-02T20:45:16.351Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/fc/8475a15b7c3209a4a68b563dfc5e01ce74f2d8b9822372c3d30c68ab7f39/cftime-1.6.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4470cd5ef3c2514566f53efbcbb64dd924fa0584637d90285b2f983bd4ee7d97", size = 513027, upload-time = "2026-01-02T20:45:20.023Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/80/4ecbda8318fbf40ad4e005a4a93aebba69e81382e5b4c6086251cd5d0ee8/cftime-1.6.5-cp314-cp314t-win_arm64.whl", hash = "sha256:034c15a67144a0a5590ef150c99f844897618b148b87131ed34fda7072614662", size = 469065, upload-time = "2026-01-02T20:45:23.398Z" },
+]
+
+[[package]]
 name = "charset-normalizer"
 version = "3.4.7"
 source = { registry = "https://pypi.org/simple" }
@@ -1203,6 +1230,33 @@ wheels = [
 ]
 
 [[package]]
+name = "netcdf4"
+version = "1.7.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "cftime" },
+    { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/38/de/38ed7e1956943d28e8ea74161e97c3a00fb98d6d08943b4fd21bae32c240/netcdf4-1.7.4-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:dec70e809cc65b04ebe95113ee9c85ba46a51c3a37c058d2b2b0cadc4d3052d8", size = 23427499, upload-time = "2026-01-05T02:27:06.568Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/70/2f73c133b71709c412bc81d8b721e28dc6237ba9d7dad861b7bfbb70408a/netcdf4-1.7.4-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:75cf59100f0775bc4d6b9d4aca7cbabd12e2b8cf3b9a4fb16d810b92743a315a", size = 22847667, upload-time = "2026-01-05T02:27:09.421Z" },
+    { url = "https://files.pythonhosted.org/packages/77/ce/43a3c0c41a6e2e940d87feea79d29aa88302211ac122604838f8a5a48de6/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddfc7e9d261125c74708119440c85ea288b5fee41db676d2ba1ce9be11f96932", size = 10274769, upload-time = "2026-01-05T21:31:19.243Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/7a/a8d32501bb95ecff342004a674720164f95ad616f269450b3bc13dc88ae3/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72c9f58767779ec14cb7451c3b56bdd8fdc027a792fac2062b14e090c5617f3", size = 10123122, upload-time = "2026-01-05T21:31:22.773Z" },
+    { url = "https://files.pythonhosted.org/packages/18/68/e89b4fa9242e59326c849c39ce0f49eb68499603c639405a8449900a4f15/netcdf4-1.7.4-cp311-abi3-win_amd64.whl", hash = "sha256:9476e1f23161ae5159cd1548c50c8a37922e77d76583e247133f256ef7b825fc", size = 21299637, upload-time = "2026-01-05T02:27:11.856Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/fc/edd41a3607241027aa4533e7f18e0cd647e74dde10a63274c65350f59967/netcdf4-1.7.4-cp311-abi3-win_arm64.whl", hash = "sha256:876ad9d58f09c98741c066c726164c45a098a58fb90e5fac9e74de4bb8a793fd", size = 2386377, upload-time = "2026-01-05T02:27:13.808Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/2b/684b15dd4791f8be295b2f6fa97377bbc07a768478a63b7d3c4951712e36/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5841de0735e8e4875b367c668e81d334287858d64dd9f3e3e2261e808c84922", size = 10395635, upload-time = "2026-01-05T02:27:19.655Z" },
+    { url = "https://files.pythonhosted.org/packages/37/dc/44d21524cf1b1c64254f92e22395a7a10f70c18f3a13a18ac9db258760f7/netcdf4-1.7.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fac03a8c5b250d57866e7d98918a64742e4b0de1681c5c86bac5726bab8aee", size = 10237725, upload-time = "2026-01-05T02:27:22.298Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/9d/c3ddf54296ad8f18f02f77f23452bdb0971aece1b87e84bab9d734bf72cc/netcdf4-1.7.4-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ad083d260301b5add74b1669c75ab0df03bdf986decfcc092cb45eec2615b5f1", size = 23515258, upload-time = "2026-01-05T02:27:24.837Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/44/bc0346e995d436d03fab682b7fbd2a9adcf0db6a05790b8f24853bf08170/netcdf4-1.7.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f22014092cc9da3f056b0368e2e38c42afd5725c87ad4843eb2f467e16dd4f6", size = 22910171, upload-time = "2026-01-05T02:27:27.166Z" },
+    { url = "https://files.pythonhosted.org/packages/30/6b/f9bc3f43c55e2dac72ee9f98d77860789bdd5d50c29adf164a6bdb303078/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224a15434c165a5e0225e5831f591edf62533044b1ce62fdfee815195bbd077d", size = 10567579, upload-time = "2026-01-05T02:27:29.382Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/d5/e7685c66b7f011c73cd746127f986358a26c642a4e4a1aa5ab51481b6586/netcdf4-1.7.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31a2318305de6831a18df25ad0df9f03b6d68666af0356d4f6057d66c02ffeb6", size = 10255032, upload-time = "2026-01-05T02:27:31.744Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/14/7506738bb6c8bc373b01e5af8f3b727f83f4f496c6b108490ea2609dc2cf/netcdf4-1.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:6c4a0aa9446c3a616ef3be015b629dc6173643f8b09546de26a4e40e272cd1ed", size = 22289653, upload-time = "2026-01-05T02:27:34.294Z" },
+    { url = "https://files.pythonhosted.org/packages/af/2e/39d5e9179c543f2e6e149a65908f83afd9b6d64379a90789b323111761db/netcdf4-1.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:034220887d48da032cb2db5958f69759dbb04eb33e279ec6390571d4aea734fe", size = 2531682, upload-time = "2026-01-05T02:27:37.062Z" },
+]
+
+[[package]]
 name = "networkx"
 version = "3.6.1"
 source = { registry = "https://pypi.org/simple" }