Module Design Philosophy¶
The cstar_forge.forge.source_data module provides a registry-based framework for managing heterogeneous source datasets used in ROMS preprocessing. The design emphasizes:
Extensibility: New datasets can be added by registering handler functions without modifying core logic
Dependency Management: Each dataset declares its requirements (grid, time range, etc.) explicitly
Caching: Datasets are downloaded/cached locally and reused across runs, under a root directory injected by the caller (not resolved internally)
Abstraction: User-facing logical names (e.g., “GLORYS”) map to implementation-specific dataset keys (e.g., “GLORYS_REGIONAL”)
The module is split across two files:
cstar_forge/forge/source_data.py— theSourceDatadataclass, the@register_datasetdecorator,DATASET_REGISTRY, and every dataset handler function. This is the “heavy” layer: it importscopernicusmarine,gdown, androms_tools.cstar_forge/forge/source_registry.py— a dependency-light, stdlib-only module holdingSOURCE_ALIAS,STREAMABLE_SOURCES,UNSTAGED_DATASETS,DATASET_METADATA, and the resolution helpers (map_source_to_dataset_key,resolve_dataset_key,resolve_source). It exists so the alias/metadata tables can be imported by lightweight callers (e.g. the blueprint resolver) without pulling in the acquisition dependencies. It also holds the versioned dataset-id/URL constants (SRTM15_URL,GLORYS_DATASET_ID,MBL_CO2_URL,WOA_DOWNLOAD_URL,UNIFIED_BGC_URL/UNIFIED_BGC_VERSION/UNIFIED_BGC_FILENAME,GLOFAS_CDS_URL).source_data.pyre-exports the alias map, the streamable/unstaged sets, the versioned URL constants, andmap_source_to_dataset_keyfor existing consumers, sofrom cstar_forge.forge.source_data import SOURCE_ALIASstill works;DATASET_METADATA,resolve_dataset_key, andresolve_sourcemust be imported fromcstar_forge.forge.source_registrydirectly.
Core Architecture¶
The module consists of three main components:
Registry System: Decorator-based registration of dataset handlers (
source_data.py)SourceData Class: Main interface for preparing and accessing datasets (
source_data.py)Source Name Mapping: Translation layer between logical names and dataset keys (
source_registry.py, re-exported fromsource_data.py)
User Request ("GLORYS")
↓
SOURCE_ALIAS mapping
↓
Dataset Key ("GLORYS_REGIONAL")
↓
DATASET_REGISTRY lookup
↓
DatasetHandler (function + requirements)
↓
Handler execution → Path(s) to prepared dataCore Objects¶
SourceData (Dataclass)¶
The main interface for preparing and accessing source datasets.
Constructor:
SourceData(
datasets: list[str], # Dataset names to prepare
clobber: bool = False, # Force re-download if True
grid: object | None = None,
grid_name: str | None = None,
start_time: object | None = None,
end_time: object | None = None,
source_data_dir: Path | None = None, # Cache root — injected by the caller
resolved_datasets: dict[str, dict] | None = None, # Optional frozen key/metadata snapshot
)SourceData no longer resolves a cache directory from cstar_forge.config internally — the caller (typically ForgeExecutor) must inject source_data_dir, e.g. from a HostPaths.source_data_cache built by cstar_forge.config.resolve_host(). If source_data_dir is None, prepare_all() raises ValueError for the first dataset it actually stages (unstaged/streamable skips happen first).
Key Attributes:
datasets: Normalized list of dataset keys (after alias resolution)paths: Dictionary mapping dataset keys to prepared file pathssource_data_dir: Cache root under which all datasets are staged (host-injected; required to callprepare_all())resolved_datasets: Optional{logical_name: {dataset_key, dataset_id, url, streamable}}snapshot, typically frozen into aForgeBlueprintat build time. When present,dataset_key_for_source()/streamable_for_source()prefer it over livesource_registrylookups, so a blueprint resolves identically even if the registry drifts later on a different host/forge version.Optional attributes (
grid,start_time, etc.) are only required if a dataset handler declares them
Key Methods:
prepare_all(include_streamable=False): Prepare all requested datasetspath_for_source(logical_name, glorys_layout=None): Get path for a logical source name (e.g., “GLORYS”)dataset_key_for_source(logical_name, glorys_layout=None): Map logical name to dataset keystreamable_for_source(logical_name, glorys_layout=None): Whether a logical name is streamable (see below)
Lifecycle:
Initialization: Normalizes dataset names through
SOURCE_ALIAS, validates againstDATASET_REGISTRY | UNSTAGED_DATASETSPreparation:
prepare_all()iterates through datasets, skipsUNSTAGED_DATASETS(e.g.ETOPO5,DAI— provided by roms-tools itself, never staged by Forge) and, unlessinclude_streamable=True, skipsSTREAMABLE_SOURCES; for the rest it checks requirements and calls handler functionsStorage: Handler return values (Path, List[Path], or Dict) are stored in
self.paths.prepare_all()overwritesself.paths[key]with the handler’s return value, so a handler’s ownself.paths[...]assignment only matters when the handler is called directly
DatasetHandler (Class)¶
Container for a dataset preparation function and its dependency requirements.
class DatasetHandler:
def __init__(self, func: Callable[["SourceData"], Path], requires: List[str]):
self.func = func # Handler function
self.requires = requires # Required SourceData attributesPurpose:
Encapsulates handler function and metadata
Enables dependency checking before handler execution
Stored in
DATASET_REGISTRYkeyed by dataset name
Registry Framework¶
Registration Decorator¶
The @register_dataset decorator registers dataset preparation functions:
@register_dataset(
name: str, # Dataset key (e.g., "GLORYS_REGIONAL")
requires: Optional[List[str]] = None # Required SourceData attributes
)
def _prepare_dataset(self: SourceData) -> Union[Path, List[Path], Dict]:
"""Handler function that prepares the dataset."""
# Implementation...
return path_or_pathsRegistration Process:
Decorator captures function and requirements
Creates
DatasetHandlerinstanceStores in
DATASET_REGISTRYwith uppercase key
Example:
@register_dataset(
"GLORYS_REGIONAL",
requires=["grid", "grid_name", "start_time", "end_time"]
)
def _prepare_glorys_regional(self: SourceData) -> List[Path]:
"""Download daily regional GLORYS subsets."""
bounds = rt.get_glorys_bounds(self.grid)
paths = self._prepare_glorys_daily(is_regional=True, bounds=bounds)
self.paths["GLORYS_REGIONAL"] = paths[0] if len(paths) == 1 else paths
return pathsRegistry Dictionary¶
DATASET_REGISTRY: Dict[str, DatasetHandler] (defined in source_data.py, alongside the handlers it registers) maps dataset keys to their handlers.
Keys are uppercase (normalized during registration)
Values are
DatasetHandlerinstancesPopulated at module import time via decorators
Handler Function Signature¶
Handler functions must:
Accept
self: SourceDataas first parameter, whichprepare_allpasses explicitly (handler.func(self)) — these are module-level functions, not methodsReturn
Path,List[Path], orDict[str, Path](stored inself.paths[dataset_key])Access required attributes via
self(e.g.,self.grid,self.start_time)Use
self.clobberto determine if re-download is neededStore result in
self.paths[dataset_key](convention, not required)
Source Name Mapping¶
Logical Names vs. Dataset Keys¶
Users specify logical source names in a ForcingSpec (catalog/ForcingSpec/<name>/Forcing.yaml); the topography source is Domain-level (model.yaml no longer carries source selection):
"GLORYS"→ maps to"GLORYS_REGIONAL"or"GLORYS_GLOBAL"(platform-dependent)"UNIFIED"→ maps to"UNIFIED_BGC""SRTM15"→"SRTM15"(un-versioned key; the version appears in the staged filenameSRTM15_V2.7.nc)
SOURCE_ALIAS Dictionary¶
Defined in cstar_forge/forge/source_registry.py (re-exported from source_data.py). Maps logical names to dataset registry keys:
SOURCE_ALIAS: dict[str, str] = {
"ERA5": "ERA5",
"GLORYS": "GLORYS_REGIONAL", # defaults to regional; see resolve_dataset_key
"GLORYS_GLOBAL": "GLORYS_GLOBAL",
"GLORYS_REGIONAL": "GLORYS_REGIONAL",
"UNIFIED": "UNIFIED_BGC",
"UNIFIED_BGC": "UNIFIED_BGC",
"SRTM15": "SRTM15",
"MBL_CO2": "MBL_CO2",
"TPXO": "TPXO",
"WOA": "WOA",
"DAI": "DAI",
"GLOFAS": "GLOFAS",
"EMOD": "EMOD",
"RIVR2O": "RIVR2O",
"CONSTANTS": "CONSTANTS",
}Mapping Function:
def map_source_to_dataset_key(name: str) -> str:
"""Map logical name to dataset key, or return uppercased name if no alias."""
return SOURCE_ALIAS.get(name.upper(), name.upper())resolve_dataset_key(name, glorys_layout=None) wraps this and additionally handles the one case the table can’t: logical "GLORYS" disambiguated by an explicit glorys_layout ("global" vs. "regional", defaulting to regional).
Normalization:
SourceData.__post_init__()normalizes all dataset names throughSOURCE_ALIASUnknown names are uppercased and used as-is (must exist in
DATASET_REGISTRYorUNSTAGED_DATASETS, or__post_init__raisesValueError)
Streamable and Unstaged Sources¶
Some datasets don’t require local caching (e.g., ERA5, DAI, CONSTANTS). Listed in STREAMABLE_SOURCES (source_registry.py):
STREAMABLE_SOURCES = ["ERA5", "DAI", "CONSTANTS"]Skipped by default in
prepare_all()unlessinclude_streamable=Truepath_for_source()returnsNonefor streamable sources if not preparedCONSTANTSis streamable but has no registry entry at all — the resolver strips it upstream; passing it toSourceDataraisesValueError: Unknown dataset(s) requested
A distinct set, UNSTAGED_DATASETS = {"ETOPO5", "DAI"}, covers recognized keys that Forge never stages at all — no @register_dataset handler exists because something else supplies the file (roms-tools auto-fetches ETOPO5 at grid-build time; DAI is streamed). SourceData.__post_init__() treats these as valid-but-skipped, distinguishing them from a genuinely unknown/typo’d name, which still raises. prepare_all() always skips them, even with include_streamable=True.
Adding a New Dataset¶
Step 1: Implement Handler Function¶
@register_dataset("MY_DATASET", requires=["grid", "grid_name"])
def _prepare_my_dataset(self: SourceData) -> Path:
"""
Prepare MY_DATASET for the given grid.
Returns
-------
Path
Path to the prepared dataset file.
"""
dataset_dir = self.source_data_dir / "MY_DATASET"
dataset_dir.mkdir(parents=True, exist_ok=True)
path = dataset_dir / f"my_dataset_{self.grid_name}.nc"
needs_download = self.clobber or (not path.exists())
if needs_download:
if path.exists():
print(f"⚠️ Clobber=True: removing existing file {path.name}")
path.unlink()
print(f"⬇️ Downloading MY_DATASET → {path}")
# Download/prepare logic here...
else:
print(f"✔️ Using existing MY_DATASET: {path}")
self.paths["MY_DATASET"] = path
return pathStep 2: Add Source Alias (if needed)¶
If users should reference it by a logical name, add the entry to SOURCE_ALIAS in cstar_forge/forge/source_registry.py:
"MY_SOURCE": "MY_DATASET",Step 3: Add to Streamable Sources (if applicable)¶
If the dataset doesn’t need local caching, add it to STREAMABLE_SOURCES in cstar_forge/forge/source_registry.py:
"MY_DATASET",Step 4: Add a DATASET_METADATA Entry¶
Add a provenance entry to DATASET_METADATA in cstar_forge/forge/source_registry.py ({"dataset_id": ...} or {"url": ...}; {} for user-staged datasets) so provenance is snapshotted into ForgeBlueprint.forcing.resolved_datasets.
Example Dataset Handlers¶
Simple Download Handler (SRTM15)¶
@register_dataset("SRTM15")
def _prepare_srtm15(self: SourceData) -> Path:
"""Download SRTM15 bathymetry."""
path = self.source_data_dir / "SRTM15" / f"SRTM15_{SRTM15_VERSION}.nc"
if self.clobber or not path.exists():
# Download logic...
pass
self.srtm15_path = path
return pathCharacteristics:
No requirements (works for any grid/time)
Returns single
PathStores a convenience attribute (
self.srtm15_path);self.paths["SRTM15"]is set byprepare_all
Time-Dependent Handler (GLORYS)¶
@register_dataset(
"GLORYS_REGIONAL",
requires=["grid", "grid_name", "start_time", "end_time"]
)
def _prepare_glorys_regional(self: SourceData) -> List[Path]:
"""Download daily regional GLORYS subsets."""
bounds = rt.get_glorys_bounds(self.grid)
paths = self._prepare_glorys_daily(is_regional=True, bounds=bounds)
# Store single path or list depending on count
self.paths["GLORYS_REGIONAL"] = paths[0] if len(paths) == 1 else paths
return pathsCharacteristics:
Requires grid and time range
Returns
List[Path](one per day)Uses helper method
_prepare_glorys_daily()for iteration
User-Provided Dataset Handler (TPXO)¶
@register_dataset("TPXO")
def _prepare_tpxo(self: SourceData) -> Dict[str, Path]:
"""Verify user-provided TPXO tidal data exists."""
tpxo_path = self.source_data_dir / "TPXO" / "TPXO10.v2a"
# Verify files exist
tpxo_dict = {
"grid": tpxo_path / "grid_tpxo10v2a.nc",
"h": tpxo_path / "h_tpxo10.v2a.nc",
"u": tpxo_path / "u_tpxo10.v2a.nc",
}
# Validation logic...
self.paths["TPXO"] = tpxo_path
return tpxo_dictCharacteristics:
No download (user must provide)
Returns
Dict[str, Path](multiple files)Validates file existence
Usage Pattern¶
from cstar_forge.forge.source_data import SourceData
from cstar_forge.config import resolve_host
from datetime import datetime
host = resolve_host(working_dir="~/cstar/_forge_bp_runs/my-grid")
# Create SourceData instance
src = SourceData(
datasets=["GLORYS", "UNIFIED", "SRTM15"], # Logical names
clobber=False,
grid=my_grid,
grid_name="my-grid",
start_time=datetime(2024, 1, 1),
end_time=datetime(2024, 1, 2),
source_data_dir=host.source_data_cache,
)
# Prepare all datasets
src.prepare_all()
# Access prepared paths
glorys_path = src.path_for_source("GLORYS") # Returns Path or List[Path]
unified_path = src.path_for_source("UNIFIED") # Returns Path
srtm15_path = src.path_for_source("SRTM15") # Returns Path
# Or access directly
glorys_path = src.paths["GLORYS_REGIONAL"]Design Patterns¶
Dependency Injection¶
Required attributes are injected into SourceData and accessed by handlers via self. This enables:
Lazy evaluation (attributes only needed if dataset is requested)
Clear dependency declaration via
requiresparameterRuntime validation before handler execution
Caching Strategy¶
Files are cached in
self.source_data_dir / {dataset_name} /, wheresource_data_diris injected by the caller (e.g.ForgeExecutor, fromHostPaths.source_data_cache) —source_data.pyno longer importscstar_forge.configto resolve this path itself (some handlers nest one level deeper or use a fixed filename — e.g.TPXO/TPXO10.v2a/,GLOFAS/glofas_v4_rivers_daily.nc)Existence check:
if self.clobber or (not path.exists())Clobber mode: Remove existing file before download
Return Value Flexibility¶
Handlers can return:
Path: Single fileList[Path]: Multiple files (e.g., daily time series)Dict[str, Path]: Named file collection (e.g., TPXO with grid/h/u files)
All are stored in self.paths[dataset_key] for uniform access.