Adding Forcing Configurations#
This guide explains how to add a new forcing configuration to CrocoDash. Forcing configurations handle the setup of different components like tides, biogeochemistry, rivers, and more.
Overview#
The forcing configuration framework in the CrocoDash.forcing package (forcing/base.py) uses a declarative, registry-based approach:
BaseConfigurator- Abstract base class that all forcing configurations inherit from@registerdecorator - Automatically registers configurators inForcingConfigRegistryInput/Output Parameters - Declarative metadata that describe what data a configurator needs and what case parameters it modifies
The workflow is:
User provides compset and configuration inputs (dates, file paths, options)
ForcingConfigRegistryvalidates and instantiates active configurators based on compsetEach configurator processes user and case inputs and generates outputs (xmlchange, user_nl modifications)
Configuration is serialized to
config.jsoncrocodash process/run_workflow()reads that JSON back and calls each configurator’sprocess_*method
Step 1: Create Your Configurator Class#
Inherit from BaseConfigurator:
from CrocoDash.forcing.base import (
BaseConfigurator,
register,
InputFileParam,
InputValueParam,
UserNLConfigParam,
XMLConfigParam,
ConfigOutputParam,
)
@register
class MyConfigurator(BaseConfigurator):
"""Configure MyComponent for the case."""
name = "my_component"
required_for_compsets = [] # e.g., ["BGC"] means required if "BGC" in compset
allowed_compsets = [] # e.g., ["MOM6"] means only allowed if "MOM6" in compset
forbidden_compsets = [] # e.g., ["CICE"] means incompatible if "CICE" in compset
The decorator automatically registers your class in ForcingConfigRegistry.registered_types.
Step 2: Define Input Parameters#
Declare what inputs your configurator needs as class variables:
@register
class MyConfigurator(BaseConfigurator):
name = "my_component"
input_params = [
InputFileParam(
"data_filepath",
comment="Path to input data file"
),
InputValueParam(
"processing_mode",
comment="How to process data: 'fast' or 'accurate'"
),
# Values injected from the Case object are still input params -- they
# must be declared here or __init__ will raise "Unexpected inputs".
InputValueParam("case_inputdir", comment="Case input directory"),
]
Parameter Types:
InputValueParam- Simple values (strings, numbers, booleans)InputFileParam- File paths (automatically validated as existing files)
Important
BaseConfigurator.__init__ calls validate_args(), which requires the set of
keyword arguments you pass to super().__init__() to exactly match the set
of declared input_params names — no missing entries, no extras. That includes
case_-prefixed arguments: they are injected by the registry rather than by the
user, but they are still ordinary input params and must be declared. Forgetting
one raises ValueError: Unexpected inputs: {'case_inputdir'}.
Important: Input parameters must be JSON serializable. They’re serialized into config.json so the processing step can rebuild the configurator for heavy computational work.
Step 3: Define Output Parameters#
Declare what CESM/MOM6 case parameters this configurator will modify:
@register
class MyConfigurator(BaseConfigurator):
name = "my_component"
input_params = [...]
output_params = [
XMLConfigParam(
"MY_COMPONENT_ENABLED",
is_non_local=False,
comment="Enable MyComponent"
),
UserNLConfigParam(
"my_component_data_file",
user_nl_name="mom",
comment="Path to component data in user_nl_mom"
),
]
Parameter Types:
XMLConfigParam- CESM XML configuration (applied viaxmlchange)is_non_local=Falsefor regular XML settingsis_non_local=Truefor non-local settings (experiment-specific)
UserNLConfigParam- Namelist parameters (written touser_nl_<component>files)user_nl_namespecifies which component file (default: “mom”)
ConfigOutputParam- A derived value with no case-side effect: notxmlchange’d, not written touser_nl_*. It exists soset_output_param()andserialize()pick the value up intoconfig.json, for values consumed only at process time (by your ownprocess_*method, or a sibling’s).
Step 4: Implement the __init__ Method#
Define what inputs your configurator accepts:
@register
class MyConfigurator(BaseConfigurator):
name = "my_component"
input_params = [...]
output_params = [...]
def __init__(
self,
data_filepath: str,
processing_mode: str,
case_caseroot: str = None, # Optional: access Case object attributes
case_inputdir: str = None,
):
"""
Initialize configurator.
Parameters
----------
data_filepath : str
Path to input data file
processing_mode : str
Processing mode: 'fast' or 'accurate'
case_caseroot : str, optional
Case root directory (passed from Case object)
case_inputdir : str, optional
Case input directory (passed from Case object)
"""
# Validate inputs before passing to parent
if processing_mode not in ["fast", "accurate"]:
raise ValueError(f"Unknown processing_mode: {processing_mode}")
# Call parent constructor (handles input parameter binding)
super().__init__(
data_filepath=data_filepath,
processing_mode=processing_mode,
case_caseroot=case_caseroot,
case_inputdir=case_inputdir,
)
Key Points:
__init__parameters must somehow fillinput_paramsnamesParameters starting with
case_come from theCaseobject, the registry automatically injects theseOther parameters are user-provided and required unless you have defaults
Call
super().__init__(**kwargs)to let the framework handle parameter binding
Step 5: Implement the configure Method#
This method processes inputs and sets output parameter values:
@register
class MyConfigurator(BaseConfigurator):
# ... class variables and __init__ ...
def configure(self):
"""Process inputs and set output parameters."""
# Access input values via attribute access
data_file = self.data_filepath # Accesses input parameter
mode = self.processing_mode
# Process/validate
if not Path(data_file).exists():
raise FileNotFoundError(f"Data file not found: {data_file}")
# Set output parameter values
self.set_output_param("MY_COMPONENT_ENABLED", "true")
self.set_output_param("my_component_data_file", str(data_file))
# Apply all output parameters (writes to case)
super().configure()
Warning
End configure() with super().configure() rather than looping over
self.output_params and calling param.apply() yourself. The base
implementation propagates the case’s is_non_local flag onto every
XMLConfigParam before applying it; a hand-rolled loop skips that and issues
xmlchange without --non-local, which silently writes to the wrong place on a
non-local case.
Step 5b: Declare a Process Step (if your forcing generates files)#
configure() only touches the case. If your configurator also has work to do at
processing time — downloading, regridding, writing netCDF — declare it in
process_components, mapping a CLI flag name onto a method name:
@register
class MyConfigurator(BaseConfigurator):
name = "my_component"
process_components = {"mycomponent": "process"}
def process(self, ctx):
"""Generate this forcing's files. `ctx` is the WorkflowContext."""
...
This is what wires you into the workflow:
crocodash processgrows a--mycomponentflag automatically (cli.pybuilds its flag list fromForcingConfigRegistry.all_process_flags())--alland--skippick it up with no further changesforcing/driver.pydispatches to it viaForcingConfigRegistry.resolve_process_targets()
A configurator that only sets namelist defaults leaves process_components
empty. One whose forcing type is one-to-many declares more than one entry —
ConditionsConfigurator uses {"ic": "process_ic", "bc": "process_bc"}.
If your process_* method needs a value another configurator computed, declare
that coupling in depends_on_outputs rather than reaching into
ctx.config[...] directly:
depends_on_outputs = {"conditions": ["date_format"]}
Processing may run in a different process than configure(), so the value comes
back through config.json via
ForcingConfigRegistry.get_configurator_output(). Declaring it lets driver.py
derive processing order automatically, and lets a test statically confirm the
named output actually exists on the target class.
Step 6: Add Compset Compatibility Logic (Optional)#
Define when your configurator is required or allowed:
@register
class MyConfigurator(BaseConfigurator):
name = "my_component"
required_for_compsets = ["MY_COMPONENT"] # Required if compset contains "MY_COMPONENT"
allowed_compsets = ["MOM6"] # Only allowed if "MOM6" in compset
forbidden_compsets = ["CICE"] # Never allowed if "CICE" in compset
# ... rest of class ...
The registry uses these to:
required_for_compsets- Throw error if compset matches but inputs are missingallowed_compsets- Skip if compset doesn’t match all listed stringsforbidden_compsets- Skip if compset matches any forbidden string
Complete Example#
Here’s a complete example of adding a simple configurator:
from pathlib import Path
from CrocoDash.forcing.base import (
BaseConfigurator,
register,
InputFileParam,
InputValueParam,
UserNLConfigParam,
XMLConfigParam,
)
@register
class IceForcing(BaseConfigurator):
"""Configure ice boundary conditions."""
name = "ice_forcing"
required_for_compsets = []
allowed_compsets = ["CICE"]
forbidden_compsets = []
process_components = {"iceforcing": "process"}
input_params = [
InputFileParam(
"ice_data_file",
comment="Path to ice initial conditions"
),
InputValueParam(
"ice_concentration_threshold",
comment="Minimum ice concentration to include"
),
# Injected by the registry from the Case -- still must be declared.
InputValueParam(
"case_inputdir",
comment="Case input directory"
),
]
output_params = [
XMLConfigParam(
"ICE_DATA_FILE",
comment="Ice data location in case"
),
UserNLConfigParam(
"ice_conc_min",
user_nl_name="cice",
comment="Ice concentration threshold"
),
]
def __init__(
self,
ice_data_file: str,
ice_concentration_threshold: str,
case_inputdir: str = None,
):
"""Initialize ice forcing configurator."""
# Validate
try:
threshold = float(ice_concentration_threshold)
except (TypeError, ValueError):
raise ValueError(
f"ice_concentration_threshold must be numeric, "
f"got {ice_concentration_threshold!r}"
)
if not (0 <= threshold <= 1):
raise ValueError("Threshold must be between 0 and 1")
super().__init__(
ice_data_file=ice_data_file,
ice_concentration_threshold=ice_concentration_threshold,
case_inputdir=case_inputdir,
)
def configure(self):
"""Configure ice boundary conditions."""
data_file = self.ice_data_file
threshold = self.ice_concentration_threshold
# Validate file exists
if not Path(data_file).exists():
raise FileNotFoundError(f"Ice data file not found: {data_file}")
# Copy to case input directory if provided
if self.case_inputdir:
dest = Path(self.case_inputdir) / Path(data_file).name
import shutil
shutil.copy(data_file, dest)
data_file = str(dest)
# Set outputs, then let the base class apply them
self.set_output_param("ICE_DATA_FILE", data_file)
self.set_output_param("ice_conc_min", threshold)
super().configure()
def process(self, ctx):
"""Generate the ice forcing file (runs under `crocodash process`)."""
...
Three details in that example are easy to get wrong, and all three are checked at runtime:
case_inputdirappears in bothinput_paramsand__init__.configure()ends withsuper().configure(), not a manualapply()loop.The bare
except ValueErroraroundfloat(...)would otherwise also swallow the “must be between 0 and 1” error and re-raise it with a misleading message, so the range check sits outside thetry.
Important Considerations#
JSON Serialization#
Input and output parameters are serialized to JSON for the extract_forcings module:
# ✅ Good - JSON serializable
input_params = [
InputFileParam("file_path"), # str
InputValueParam("threshold"), # str
]
# ❌ Bad - Not JSON serializable
input_params = [
InputFileParam("data_object"), # xarray.Dataset
InputFileParam("case_object"), # Case instance
]
If you need to pass complex objects to extract_forcings, store serializable references (paths, identifiers) instead.
Validation Strategy#
Validation should happen in validate_args, which is called from the base
__init__. Always call super().validate_args(**kwargs) first — the base
implementation is what enforces that your declared input_params and the
constructor kwargs line up. Dropping it silently disables that check:
def validate_args(self, **kwargs):
super().validate_args(**kwargs)
assert kwargs["my_required_param"] is not None
Accessing Case Information#
To access Case object attributes, use the case_ prefix:
def __init__(self, some_param: str, case_caseroot: str = None, case_compset: str = None):
# case_caseroot and case_compset are optional
# They're injected by ForcingConfigRegistry if available
super().__init__(
some_param=some_param,
case_caseroot=case_caseroot,
case_compset=case_compset,
)
Available case attributes are whatever objects are on the Case object. Remember
that each one must also be declared in input_params (see Step 2).
A configurator can also check whether a sibling configurator is active through
self.registry, which ForcingConfigRegistry injects after all active
configurators are instantiated:
if self.registry.is_active("tides"):
...
Testing Your Configurator#
Most basic testing is taken care of, and the registry will run available configurators. Only add additional tests if your configurator has unique configure/method behavior.
See Also#
Architecture — how
CrocoDash.forcingfits into the rest of CrocoDashSubmodule API Usage — external entry points used by configurators
Example implementations in
CrocoDash/forcing/(tides.py,chl.py,runoff.py,bgc.py,mom6.py)