Nicholas Schense пре 2 недеља
родитељ
комит
9a2c1b5cbb
8 измењених фајлова са 249 додато и 81 уклоњено
  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
 droprate = 0.05
 learning_rate = 0.0001
 learning_rate = 0.0001
 num_epochs = 25
 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 pathlib as pl
 import random
 import random
 import re
 import re
@@ -10,6 +11,15 @@ import torch.utils.data as data
 from jaxtyping import Float
 from jaxtyping import Float
 from torch.utils.data import DataLoader, Subset
 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:
 def _row_to_float_tensor(row: pd.DataFrame, *, image_id: int) -> torch.Tensor:
     values = row.drop(columns=["Image Data ID"]).iloc[0]
     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.
     Preprocess the Excel DataFrame.
     This function can be customized to filter or modify the DataFrame as needed.
     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
     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
         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(
             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)
         mri_data_unstacked.append(file_mri_data)
@@ -196,8 +214,8 @@ def divide_dataset(
     Returns:
     Returns:
         Result[List[data.Subset[ADNIDataset]], str]: A Result object containing the subsets or an error message.
         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
     # Set the random seed for reproducibility
     gen = torch.Generator().manual_seed(seed)
     gen = torch.Generator().manual_seed(seed)
@@ -207,27 +225,52 @@ def divide_dataset(
 def initalize_dataloaders(
 def initalize_dataloaders(
     datasets: List[Subset[ADNIDataset]],
     datasets: List[Subset[ADNIDataset]],
     batch_size: int = 64,
     batch_size: int = 64,
+    seed: int | None = None,
 ) -> List[DataLoader[ADNIDataset]]:
 ) -> 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:
     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:
     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()
     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(
 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
         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.
         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:
     if not ptids:
         raise ValueError("ptids list cannot be empty.")
         raise ValueError("ptids list cannot be empty.")

+ 2 - 0
pyproject.toml

@@ -21,6 +21,8 @@ dependencies = [
     "jaxtyping",
     "jaxtyping",
     "textual",
     "textual",
     "toml",
     "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 typing import Any, Dict
 
 
 from util.progress import ProgressTracker
 from util.progress import ProgressTracker
@@ -9,48 +26,23 @@ from . import train_bayesian
 from . import train_normal
 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 = {
 PIPELINE_TASKS = {
@@ -66,13 +58,29 @@ PIPELINE_TASKS = {
         "task_name": "Train Bayesian Models",
         "task_name": "Train Bayesian Models",
         "task_func": train_bayesian.train_bayesian_task,
         "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": {
     "evaluate_regular": {
         "task_name": "Evaluate Regular Models",
         "task_name": "Evaluate Regular Models",
-        "task_func": dummy_task,
+        "task_func": _placeholder_task(
+            "Evaluate normal ensemble and save netCDF (step 4)"
+        ),
     },
     },
     "evaluate_bayesian": {
     "evaluate_bayesian": {
         "task_name": "Evaluate Bayesian Models",
         "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",
             "load_data",
             "train_regular",
             "train_regular",
             "train_bayesian",
             "train_bayesian",
+            "load_models",
             "evaluate_regular",
             "evaluate_regular",
             "evaluate_bayesian",
             "evaluate_bayesian",
+            "evaluate_noisy",
         ],
         ],
     },
     },
     "scen_load_all": {
     "scen_load_all": {
         "label": "2. Load, Evaluate, & Noise Analysis",
         "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": {
     "scen_load_eval": {
         "label": "3. Load & Evaluate (Skip Noise)",
         "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
 import model.dataset as ds
 from util.progress import ProgressTracker
 from util.progress import ProgressTracker
+from util.seeding import seed_everything
 from util.ui_logger import PipelineLogger
 from util.ui_logger import PipelineLogger
 
 
 
 
@@ -15,6 +16,21 @@ def load_data_task(
     state: Dict[str, Any],
     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")
     log.info("Loading Files")
     mri_files = pl.Path(config["data"]["mri_files_path"]).glob("*.nii")
     mri_files = pl.Path(config["data"]["mri_files_path"]).glob("*.nii")
     xls_file = pl.Path(config["data"]["xls_file_path"])
     xls_file = pl.Path(config["data"]["xls_file_path"])
@@ -26,10 +42,6 @@ def load_data_task(
         xls_preprocessor=ds.xls_pre,
         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 = pd.read_csv(xls_file)
     ptid_df.columns = ptid_df.columns.str.strip()
     ptid_df.columns = ptid_df.columns.str.strip()
 
 
@@ -50,9 +62,12 @@ def load_data_task(
         seed=config["data"]["seed"],
         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(
     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")
     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.cnn import CNN3D
 from model.dnn_mod import dnn_to_bnn_mod, get_kl_loss
 from model.dnn_mod import dnn_to_bnn_mod, get_kl_loss
 from util.progress import ProgressTracker
 from util.progress import ProgressTracker
+from util.seeding import derive_seed, seed_everything
 from util.ui_logger import PipelineLogger
 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:
 def _release_torch_memory(device: str) -> None:
     gc.collect()
     gc.collect()
@@ -73,10 +78,16 @@ def train_bayesian_task(
         "moped_delta": 0.5,
         "moped_delta": 0.5,
     }
     }
 
 
+    base_seed = int(config["data"]["seed"])
+
     for model_num in range(config["training"]["ensemble_size"]):
     for model_num in range(config["training"]["ensemble_size"]):
         _check_control_events(stop_event=stop_event, pause_event=pause_event)
         _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(
         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(
         model = CNN3D(
             image_channels=config["data"]["image_channels"],
             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
 import model.training as tn
 from model.cnn import CNN3D
 from model.cnn import CNN3D
 from util.progress import ProgressTracker
 from util.progress import ProgressTracker
+from util.seeding import derive_seed, seed_everything
 from util.ui_logger import PipelineLogger
 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:
 def _release_torch_memory(device: str) -> None:
     gc.collect()
     gc.collect()
@@ -59,12 +64,19 @@ def train_normal_task(
         intermediate_model_dir.mkdir(parents=True, exist_ok=True)
         intermediate_model_dir.mkdir(parents=True, exist_ok=True)
     log.info(f"Intermediate models will be saved to {intermediate_model_dir}")
     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")
     train_progress = track.get_sub_tracker("Training Progress")
     track.update(total=config["training"]["ensemble_size"], advance=0)
     track.update(total=config["training"]["ensemble_size"], advance=0)
     for model_num in range(config["training"]["ensemble_size"]):
     for model_num in range(config["training"]["ensemble_size"]):
         _check_control_events(stop_event=stop_event, pause_event=pause_event)
         _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(
         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
         # Train the model
         model = (
         model = (

+ 55 - 1
uv.lock

@@ -2,7 +2,8 @@ version = 1
 revision = 3
 revision = 3
 requires-python = ">=3.14"
 requires-python = ">=3.14"
 resolution-markers = [
 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'",
     "sys_platform != 'emscripten' and sys_platform != 'win32'",
     "sys_platform != 'emscripten' and sys_platform != 'win32'",
 ]
 ]
@@ -25,6 +26,7 @@ dependencies = [
     { name = "jaxtyping" },
     { name = "jaxtyping" },
     { name = "jupyterlab" },
     { name = "jupyterlab" },
     { name = "matplotlib" },
     { name = "matplotlib" },
+    { name = "netcdf4" },
     { name = "nibabel" },
     { name = "nibabel" },
     { name = "numpy" },
     { name = "numpy" },
     { name = "pandas" },
     { name = "pandas" },
@@ -45,6 +47,7 @@ requires-dist = [
     { name = "jaxtyping" },
     { name = "jaxtyping" },
     { name = "jupyterlab" },
     { name = "jupyterlab" },
     { name = "matplotlib" },
     { name = "matplotlib" },
+    { name = "netcdf4", specifier = ">=1.7.4" },
     { name = "nibabel" },
     { name = "nibabel" },
     { name = "numpy" },
     { name = "numpy" },
     { name = "pandas" },
     { name = "pandas" },
@@ -260,6 +263,30 @@ wheels = [
 ]
 ]
 
 
 [[package]]
 [[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"
 name = "charset-normalizer"
 version = "3.4.7"
 version = "3.4.7"
 source = { registry = "https://pypi.org/simple" }
 source = { registry = "https://pypi.org/simple" }
@@ -1203,6 +1230,33 @@ wheels = [
 ]
 ]
 
 
 [[package]]
 [[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"
 name = "networkx"
 version = "3.6.1"
 version = "3.6.1"
 source = { registry = "https://pypi.org/simple" }
 source = { registry = "https://pypi.org/simple" }