| """OneScience adapter for NVIDIA's official legacy FourCastNet v2 network.""" |
|
|
| from __future__ import annotations |
|
|
| import sys |
| from pathlib import Path |
| from types import SimpleNamespace |
| from typing import Any, Mapping |
|
|
| import torch |
| from torch import nn |
|
|
|
|
| def _load_model_class(): |
| package_dir = Path(__file__).resolve().parent / "fcnv2" |
| if not (package_dir / "fcnv2_sfnonet.py").is_file(): |
| raise FileNotFoundError( |
| f"Bundled FCNv2 source was not found at {package_dir}" |
| ) |
|
|
| package_path = str(package_dir) |
| if package_path not in sys.path: |
| sys.path.insert(0, package_path) |
|
|
| try: |
| from fcnv2_sfnonet import FourierNeuralOperatorNet |
| except ModuleNotFoundError as error: |
| if error.name == "torch_harmonics": |
| raise ModuleNotFoundError( |
| "FourCastNet v2 requires NVIDIA torch-harmonics. Install the " |
| "version pinned by this project before constructing the model." |
| ) from error |
| raise |
| return FourierNeuralOperatorNet |
|
|
|
|
| def _official_params(model_config: Mapping[str, Any]) -> SimpleNamespace: |
| required = { |
| "img_size", |
| "in_channels", |
| "out_channels", |
| "spectral_transform", |
| "filter_type", |
| "scale_factor", |
| "embed_dim", |
| "num_layers", |
| "num_blocks", |
| "normalization_layer", |
| "mlp_mode", |
| "spectral_layers", |
| "complex_activation", |
| "hard_thresholding_fraction", |
| "big_skip", |
| } |
| missing = sorted(required.difference(model_config)) |
| if missing: |
| raise ValueError(f"Missing FourCastNet v2 model settings: {missing}") |
|
|
| height, width = model_config["img_size"] |
| hidden_height = height // model_config["scale_factor"] |
| hidden_width = width // model_config["scale_factor"] |
| if hidden_height < 2 or hidden_width < 2: |
| raise ValueError("The internal SFNO grid must have at least 2 x 2 points") |
|
|
| return SimpleNamespace( |
| img_crop_shape_x=int(height), |
| img_crop_shape_y=int(width), |
| N_in_channels=int(model_config["in_channels"]), |
| N_out_channels=int(model_config["out_channels"]), |
| spectral_transform=model_config["spectral_transform"], |
| filter_type=model_config["filter_type"], |
| scale_factor=int(model_config["scale_factor"]), |
| embed_dim=int(model_config["embed_dim"]), |
| num_layers=int(model_config["num_layers"]), |
| num_blocks=int(model_config["num_blocks"]), |
| normalization_layer=model_config["normalization_layer"], |
| mlp_mode=model_config["mlp_mode"], |
| spectral_layers=int(model_config["spectral_layers"]), |
| complex_activation=model_config["complex_activation"], |
| hard_thresholding_fraction=float( |
| model_config["hard_thresholding_fraction"] |
| ), |
| big_skip=bool(model_config["big_skip"]), |
| ) |
|
|
|
|
| class FourCastNetV2(nn.Module): |
| """Build the exact official FCNv2 network behind a stable project API.""" |
|
|
| def __init__( |
| self, |
| model_config: Mapping[str, Any], |
| ) -> None: |
| super().__init__() |
| self.model_config = dict(model_config) |
| self.expected_shape = ( |
| int(model_config["in_channels"]), |
| int(model_config["img_size"][0]), |
| int(model_config["img_size"][1]), |
| ) |
| model_class = _load_model_class() |
| self.model = model_class(_official_params(model_config)) |
|
|
| def forward(self, inputs: torch.Tensor) -> torch.Tensor: |
| if inputs.ndim != 4: |
| raise ValueError(f"Expected [B,C,H,W], got {tuple(inputs.shape)}") |
| if tuple(inputs.shape[1:]) != self.expected_shape: |
| raise ValueError( |
| f"Expected trailing shape {self.expected_shape}, " |
| f"got {tuple(inputs.shape[1:])}" |
| ) |
| return self.model(inputs) |
|
|
| def no_weight_decay(self) -> set[str]: |
| return {f"model.{name}" for name in self.model.no_weight_decay()} |
|
|
|
|
| def _unwrap_state_dict(checkpoint: Any) -> Mapping[str, torch.Tensor]: |
| if not isinstance(checkpoint, Mapping): |
| raise TypeError("Checkpoint must contain a mapping") |
| for key in ("model_state", "model_state_dict", "state_dict"): |
| candidate = checkpoint.get(key) |
| if isinstance(candidate, Mapping): |
| return candidate |
| if checkpoint and all(isinstance(value, torch.Tensor) for value in checkpoint.values()): |
| return checkpoint |
| raise KeyError("Checkpoint has no recognized model state mapping") |
|
|
|
|
| def _normalize_state_keys( |
| state_dict: Mapping[str, torch.Tensor], model: nn.Module |
| ) -> dict[str, torch.Tensor]: |
| target_keys = set(model.state_dict()) |
| normalized: dict[str, torch.Tensor] = {} |
| for key, value in state_dict.items(): |
| clean_key = key |
| while clean_key.startswith("module."): |
| clean_key = clean_key[len("module.") :] |
| if clean_key not in target_keys and f"model.{clean_key}" in target_keys: |
| clean_key = f"model.{clean_key}" |
| normalized[clean_key] = value |
| return normalized |
|
|
|
|
| def load_checkpoint( |
| model: nn.Module, |
| checkpoint_path: str | Path, |
| *, |
| expected_profile: str, |
| expected_variables: list[str], |
| allowed_stages: set[str], |
| allowed_initializations: set[str], |
| strict: bool = True, |
| map_location: str | torch.device = "cpu", |
| ) -> dict[str, Any]: |
| """Load a project checkpoint without changing its parameter tensors.""" |
|
|
| checkpoint = torch.load( |
| Path(checkpoint_path).expanduser(), |
| map_location=map_location, |
| weights_only=False, |
| ) |
| validate_project_checkpoint( |
| checkpoint, |
| expected_profile=expected_profile, |
| expected_variables=expected_variables, |
| allowed_stages=allowed_stages, |
| allowed_initializations=allowed_initializations, |
| ) |
| state_dict = _normalize_state_keys(_unwrap_state_dict(checkpoint), model) |
| incompatible = model.load_state_dict(state_dict, strict=strict) |
| return { |
| "checkpoint": checkpoint, |
| "missing_keys": list(incompatible.missing_keys), |
| "unexpected_keys": list(incompatible.unexpected_keys), |
| } |
|
|
|
|
| def validate_project_checkpoint( |
| checkpoint: Any, |
| *, |
| expected_profile: str, |
| expected_variables: list[str], |
| allowed_stages: set[str], |
| allowed_initializations: set[str], |
| ) -> None: |
| if not isinstance(checkpoint, Mapping): |
| raise TypeError("Project checkpoint must contain metadata") |
| expected = { |
| "checkpoint_format": "fourcastnet_v2_project", |
| "scratch_lineage": True, |
| "model_profile": expected_profile, |
| "variables": expected_variables, |
| } |
| for key, value in expected.items(): |
| if checkpoint.get(key) != value: |
| raise ValueError( |
| f"Checkpoint metadata {key!r} does not match the project config" |
| ) |
| if checkpoint.get("stage") not in allowed_stages: |
| raise ValueError( |
| f"Checkpoint stage must be one of {sorted(allowed_stages)}" |
| ) |
| if checkpoint.get("initialization") not in allowed_initializations: |
| raise ValueError( |
| "Checkpoint does not have an approved random-initialization lineage" |
| ) |
| expected_initialization = { |
| "one_step": "random", |
| "finetune": "one_step_checkpoint", |
| }.get(checkpoint.get("stage")) |
| if checkpoint.get("initialization") != expected_initialization: |
| raise ValueError( |
| "Checkpoint stage and initialization metadata are inconsistent" |
| ) |
|
|