Skip to content

Feat: Sub-options for model physics options #972

@dayantur

Description

@dayantur

Proposed feature

Allow model physics options to be specified as a nested mapping with sub-options while keeping legacy scalar form supported for backward compatibility.

Why

  • Makes intent explicit in YAML (which implementation of a model option is being selected)
  • Improves docs and validation by naming the chosen family alongside the numeric code

Netradiationmethod example

N.B: The following is not written in the stone and is actually just a first draft proposal of how this new feature could be implemented.

New structure of the YAML:

      # nested form:
      netradiationmethod:
        spartacus:
          value: 1001

      # legacy scalar (still accepted):
      netradiationmethod:
        value: 3

New NetRadiationMethod(Enum):

class NetRadiationMethod(Enum):
    OBSERVED = 0
    LDOWN_OBSERVED = 1
    LDOWN_CLOUD = 2
    LDOWN_AIR = 3
    LDOWN_SURFACE = 11
    LDOWN_CLOUD_SURFACE = 12
    LDOWN_AIR_SURFACE = 13
    LDOWN_ZENITH = 100
    LDOWN_CLOUD_ZENITH = 200
    LDOWN_AIR_ZENITH = 300
    LDOWN_SS_OBSERVED = 1001
    LDOWN_SS_CLOUD = 1002
    LDOWN_SS_AIR = 1003

    _FORCING = {0}
    _NARP = {1, 2, 3, 11, 12, 13, 100, 200, 300}
    _SPARTACUS = {1001, 1002, 1003}

    def __int__(self):
        return self.value

    @property
    def family(self) -> str:
        v = int(self)
        if v in self._FORCING:
            return "forcing"
        if v in self._NARP:
            return "narp"
        if v in self._SPARTACUS:
            return "spartacus"
        return "legacy"

    @property
    def is_forcing(self) -> bool:
        return int(self) in self._FORCING

    @property
    def is_narp(self) -> bool:
        return int(self) in self._NARP

    @property
    def is_spartacus(self) -> bool:
        return int(self) in self._SPARTACUS

New minimal nested container

class NetRadiationMethodConfig(BaseModel):
    """
    Canonical nested structure. Only these two shapes are allowed:
      - legacy scalar: {'value': 3}  (will be converted to nested by validator)
      - nested: {'narp': {'value': 3}} (or 'forcing' / 'spartacus')
    """
    forcing: Optional[dict] = None
    narp: Optional[dict] = None
    spartacus: Optional[dict] = None

    def selected(self) -> Tuple[str, Optional[NetRadiationMethod]]:
        """
        Return (family_name, NetRadiationMethod enum | None).
        Raises ValueError for invalid inner shapes (e.g. nested dict as value).
        """
        for name in ("forcing", "narp", "spartacus"):
            block = getattr(self, name)
            if block is not None:
                if not isinstance(block, dict) or "value" not in block:
                    raise ValueError(f"netradiationmethod.{name} must be a dict with a scalar 'value'")
                val = block["value"]
                if isinstance(val, dict):
                    raise ValueError(f"netradiationmethod.{name}.value must be a scalar (int/Enum), not a dict")
                if val is None:
                    return name, None
                return name, NetRadiationMethod(int(val))
        raise ValueError("netradiationmethod must provide exactly one of: forcing, narp, spartacus")

Changes in ModelPhysics

# 
netradiationmethod: NetRadiationMethodConfig = Field(
    default_factory=NetRadiationMethodConfig,
    description="Nested netradiationmethod: supply exactly one of forcing/narp/spartacus.",
)

Legacy not-nested form

This probably needs to be refactor into a general top-level helper function to be called for each model option.


# --- Minimal validator to accept legacy scalar and convert it to the nested canonical form ---
@field_validator("netradiationmethod", mode="before")
def _coerce_netrad_field(cls, v: Any) -> Any:
    """
    Accept only:
      - legacy scalar: {'value': N}  (or plain int/Enum)  -> converted to nested {'narp'|'forcing'|'spartacus': {'value': N}}
      - nested canonical: {'narp': {'value': N}} (or 'forcing'/'spartacus') -> accepted as-is
    Reject double-wrapped or other shapes.
    """
    if v is None:
        return v

    # If already nested canonical form with one of the family keys, validate inner shape quickly
    if isinstance(v, dict) and any(k in v for k in ("forcing", "narp", "spartacus")):
        # ensure exactly one family key present and inner 'value' is scalar
        keys = [k for k in ("forcing", "narp", "spartacus") if k in v]
        if len(keys) != 1:
            raise ValueError(f"netradiationmethod must provide exactly one of: forcing, narp, spartacus (got {keys})")
        inner = v[keys[0]]
        if not isinstance(inner, dict) or "value" not in inner:
            raise ValueError(f"netradiationmethod.{keys[0]} must be a dict with key 'value'")
        if isinstance(inner["value"], dict):
            raise ValueError(f"netradiationmethod.{keys[0]}.value must be a scalar, not a dict")
        return v

    # Legacy: {'value': N}
    if isinstance(v, dict) and "value" in v:
        val = v["value"]
    else:
        # allow plain Enum/int
        try:
            from enum import Enum as _Enum
            if isinstance(v, _Enum):
                val = int(v.value)
            else:
                val = int(v)
        except Exception:
            val = None

    if val is None:
        raise ValueError("netradiationmethod must be either {'value': N} or {'narp'|'forcing'|'spartacus': {'value': N}}")

    n = int(val)
    # map numeric to family
    if n in NetRadiationMethod._FORCING:
        return {"forcing": {"value": n}}
    if n in NetRadiationMethod._NARP:
        return {"narp": {"value": n}}
    if n in NetRadiationMethod._SPARTACUS:
        return {"spartacus": {"value": n}}
    # default policy: unknown numeric codes -> place in 'narp'
    return {"narp": {"value": n}}

Metadata

Metadata

Assignees

Labels

1-featureNew functionality2-infra:data-modelPydantic data models & validation2-infra:inputInput data handling and validation3-P2Medium priority4-in-progressBeing worked on

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions